mirror of
https://github.com/hydralauncher/hydra.git
synced 2025-03-09 15:40:26 +00:00
Merge branch 'feature/game-achievements' of github.com:hydralauncher/hydra into feature/cloud-sync
This commit is contained in:
commit
bdaf68ad23
48 changed files with 1597 additions and 376 deletions
|
@ -39,6 +39,7 @@ export function Header({ onSearch, onClear, search }: HeaderProps) {
|
|||
|
||||
const title = useMemo(() => {
|
||||
if (location.pathname.startsWith("/game")) return headerTitle;
|
||||
if (location.pathname.startsWith("/achievements")) return headerTitle;
|
||||
if (location.pathname.startsWith("/profile")) return headerTitle;
|
||||
if (location.pathname.startsWith("/search")) return t("search_results");
|
||||
|
||||
|
|
|
@ -3,20 +3,26 @@ import {
|
|||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import { setHeaderTitle } from "@renderer/features";
|
||||
import { getSteamLanguage } from "@renderer/helpers";
|
||||
import { useAppDispatch, useAppSelector, useDownload } from "@renderer/hooks";
|
||||
import {
|
||||
useAppDispatch,
|
||||
useAppSelector,
|
||||
useDownload,
|
||||
useUserDetails,
|
||||
} from "@renderer/hooks";
|
||||
|
||||
import type {
|
||||
Game,
|
||||
GameAchievement,
|
||||
GameRepack,
|
||||
GameShop,
|
||||
GameStats,
|
||||
ShopDetails,
|
||||
UserAchievement,
|
||||
} from "@types";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
@ -37,7 +43,7 @@ export const gameDetailsContext = createContext<GameDetailsContext>({
|
|||
showRepacksModal: false,
|
||||
showGameOptionsModal: false,
|
||||
stats: null,
|
||||
achievements: [],
|
||||
achievements: null,
|
||||
hasNSFWContentBlocked: false,
|
||||
setGameColor: () => {},
|
||||
selectGameExecutable: async () => null,
|
||||
|
@ -64,9 +70,12 @@ export function GameDetailsContextProvider({
|
|||
shop,
|
||||
}: GameDetailsContextProps) {
|
||||
const [shopDetails, setShopDetails] = useState<ShopDetails | null>(null);
|
||||
const [achievements, setAchievements] = useState<GameAchievement[]>([]);
|
||||
const [achievements, setAchievements] = useState<UserAchievement[] | null>(
|
||||
null
|
||||
);
|
||||
const [game, setGame] = useState<Game | null>(null);
|
||||
const [hasNSFWContentBlocked, setHasNSFWContentBlocked] = useState(false);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const [stats, setStats] = useState<GameStats | null>(null);
|
||||
|
||||
|
@ -93,6 +102,7 @@ export function GameDetailsContextProvider({
|
|||
const dispatch = useAppDispatch();
|
||||
|
||||
const { lastPacket } = useDownload();
|
||||
const { userDetails } = useUserDetails();
|
||||
|
||||
const userPreferences = useAppSelector(
|
||||
(state) => state.userPreferences.value
|
||||
|
@ -111,6 +121,10 @@ export function GameDetailsContextProvider({
|
|||
}, [updateGame, isGameDownloading, lastPacket?.game.status]);
|
||||
|
||||
useEffect(() => {
|
||||
if (abortControllerRef.current) abortControllerRef.current.abort();
|
||||
const abortController = new AbortController();
|
||||
abortControllerRef.current = abortController;
|
||||
|
||||
window.electron
|
||||
.getGameShopDetails(
|
||||
objectId!,
|
||||
|
@ -118,6 +132,8 @@ export function GameDetailsContextProvider({
|
|||
getSteamLanguage(i18n.language)
|
||||
)
|
||||
.then((result) => {
|
||||
if (abortController.signal.aborted) return;
|
||||
|
||||
setShopDetails(result);
|
||||
|
||||
if (
|
||||
|
@ -133,28 +149,36 @@ export function GameDetailsContextProvider({
|
|||
});
|
||||
|
||||
window.electron.getGameStats(objectId, shop as GameShop).then((result) => {
|
||||
if (abortController.signal.aborted) return;
|
||||
setStats(result);
|
||||
});
|
||||
|
||||
window.electron
|
||||
.getGameAchievements(objectId, shop as GameShop)
|
||||
.then((achievements) => {
|
||||
// TODO: race condition
|
||||
if (abortController.signal.aborted) return;
|
||||
if (!userDetails) return;
|
||||
setAchievements(achievements);
|
||||
})
|
||||
.catch(() => {
|
||||
// TODO: handle user not logged in error
|
||||
});
|
||||
.catch(() => {});
|
||||
|
||||
updateGame();
|
||||
}, [updateGame, dispatch, gameTitle, objectId, shop, i18n.language]);
|
||||
}, [
|
||||
updateGame,
|
||||
dispatch,
|
||||
gameTitle,
|
||||
objectId,
|
||||
shop,
|
||||
i18n.language,
|
||||
userDetails,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
setShopDetails(null);
|
||||
setGame(null);
|
||||
setIsLoading(true);
|
||||
setisGameRunning(false);
|
||||
setAchievements([]);
|
||||
setAchievements(null);
|
||||
dispatch(setHeaderTitle(gameTitle));
|
||||
}, [objectId, gameTitle, dispatch]);
|
||||
|
||||
|
@ -180,6 +204,7 @@ export function GameDetailsContextProvider({
|
|||
objectId,
|
||||
shop,
|
||||
(achievements) => {
|
||||
if (!userDetails) return;
|
||||
setAchievements(achievements);
|
||||
}
|
||||
);
|
||||
|
@ -187,7 +212,7 @@ export function GameDetailsContextProvider({
|
|||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [objectId, shop]);
|
||||
}, [objectId, shop, userDetails]);
|
||||
|
||||
const getDownloadsPath = async () => {
|
||||
if (userPreferences?.downloadsPath) return userPreferences.downloadsPath;
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import type {
|
||||
Game,
|
||||
GameAchievement,
|
||||
GameRepack,
|
||||
GameShop,
|
||||
GameStats,
|
||||
ShopDetails,
|
||||
UserAchievement,
|
||||
} from "@types";
|
||||
|
||||
export interface GameDetailsContext {
|
||||
|
@ -20,7 +20,7 @@ export interface GameDetailsContext {
|
|||
showRepacksModal: boolean;
|
||||
showGameOptionsModal: boolean;
|
||||
stats: GameStats | null;
|
||||
achievements: GameAchievement[];
|
||||
achievements: UserAchievement[] | null;
|
||||
hasNSFWContentBlocked: boolean;
|
||||
setGameColor: React.Dispatch<React.SetStateAction<string>>;
|
||||
selectGameExecutable: () => Promise<string | null>;
|
||||
|
|
3
src/renderer/src/declaration.d.ts
vendored
3
src/renderer/src/declaration.d.ts
vendored
|
@ -28,6 +28,7 @@ import type {
|
|||
GameAchievement,
|
||||
GameArtifact,
|
||||
LudusaviBackup,
|
||||
UserAchievement,
|
||||
} from "@types";
|
||||
import type { AxiosProgressEvent } from "axios";
|
||||
import type { DiskSpace } from "check-disk-space";
|
||||
|
@ -68,7 +69,7 @@ declare global {
|
|||
objectId: string,
|
||||
shop: GameShop,
|
||||
userId?: string
|
||||
) => Promise<GameAchievement[]>;
|
||||
) => Promise<UserAchievement[]>;
|
||||
onAchievementUnlocked: (
|
||||
cb: (
|
||||
objectId: string,
|
||||
|
|
|
@ -34,5 +34,21 @@ export const buildGameDetailsPath = (
|
|||
return `/game/${game.shop}/${game.objectId}?${searchParams.toString()}`;
|
||||
};
|
||||
|
||||
export const buildGameAchievementPath = (
|
||||
game: { shop: GameShop; objectId: string; title: string },
|
||||
user?: { userId: string; displayName: string; profileImageUrl: string | null }
|
||||
) => {
|
||||
const searchParams = new URLSearchParams({
|
||||
title: game.title,
|
||||
shop: game.shop,
|
||||
objectId: game.objectId,
|
||||
userId: user?.userId || "",
|
||||
displayName: user?.displayName || "",
|
||||
profileImageUrl: user?.profileImageUrl || "",
|
||||
});
|
||||
|
||||
return `/achievements/?${searchParams.toString()}`;
|
||||
};
|
||||
|
||||
export const darkenColor = (color: string, amount: number, alpha: number = 1) =>
|
||||
new Color(color).darken(amount).alpha(alpha).toString();
|
||||
|
|
|
@ -68,12 +68,17 @@ export function useDate() {
|
|||
}
|
||||
},
|
||||
|
||||
format: (timestamp: number): string => {
|
||||
formatDateTime: (date: number | Date | string): string => {
|
||||
const locale = getDateLocale();
|
||||
return format(
|
||||
timestamp,
|
||||
date,
|
||||
locale == enUS ? "MM/dd/yyyy - HH:mm" : "dd/MM/yyyy - HH:mm"
|
||||
);
|
||||
},
|
||||
|
||||
formatDate: (date: number | Date | string): string => {
|
||||
const locale = getDateLocale();
|
||||
return format(date, locale == enUS ? "MM/dd/yyyy" : "dd/MM/yyyy");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -67,6 +67,7 @@ export function useUserDetails() {
|
|||
return updateUserDetails({
|
||||
...response,
|
||||
username: userDetails?.username || "",
|
||||
subscription: userDetails?.subscription || null,
|
||||
});
|
||||
},
|
||||
[updateUserDetails, userDetails?.username]
|
||||
|
|
483
src/renderer/src/pages/achievement/achievements-content.tsx
Normal file
483
src/renderer/src/pages/achievement/achievements-content.tsx
Normal file
|
@ -0,0 +1,483 @@
|
|||
import { setHeaderTitle } from "@renderer/features";
|
||||
import { useAppDispatch, useDate, useUserDetails } from "@renderer/hooks";
|
||||
import { steamUrlBuilder } from "@shared";
|
||||
import { useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as styles from "./achievements.css";
|
||||
import { formatDownloadProgress } from "@renderer/helpers";
|
||||
import { PersonIcon, TrophyIcon } from "@primer/octicons-react";
|
||||
import { SPACING_UNIT, vars } from "@renderer/theme.css";
|
||||
import { gameDetailsContext } from "@renderer/context";
|
||||
import { UserAchievement } from "@types";
|
||||
import { average } from "color.js";
|
||||
import Color from "color";
|
||||
|
||||
const HERO_ANIMATION_THRESHOLD = 25;
|
||||
|
||||
interface UserInfo {
|
||||
userId: string;
|
||||
displayName: string;
|
||||
achievements: UserAchievement[];
|
||||
profileImageUrl: string | null;
|
||||
}
|
||||
|
||||
interface AchievementsContentProps {
|
||||
otherUser: UserInfo | null;
|
||||
}
|
||||
|
||||
interface AchievementListProps {
|
||||
achievements: UserAchievement[];
|
||||
otherUserAchievements?: UserAchievement[];
|
||||
}
|
||||
|
||||
interface AchievementPanelProps {
|
||||
user: UserInfo;
|
||||
otherUser: UserInfo | null;
|
||||
}
|
||||
|
||||
function AchievementPanel({ user, otherUser }: AchievementPanelProps) {
|
||||
const { t } = useTranslation("achievement");
|
||||
|
||||
const getProfileImage = (imageUrl: string | null | undefined) => {
|
||||
return (
|
||||
<div className={styles.profileAvatar}>
|
||||
{imageUrl ? (
|
||||
<img className={styles.profileAvatar} src={imageUrl} alt={"teste"} />
|
||||
) : (
|
||||
<PersonIcon size={24} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const userTotalAchievementCount = user.achievements.length;
|
||||
const userUnlockedAchievementCount = user.achievements.filter(
|
||||
(achievement) => achievement.unlocked
|
||||
).length;
|
||||
|
||||
if (!otherUser) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
width: "100%",
|
||||
padding: `0 ${SPACING_UNIT * 2}px`,
|
||||
gap: `${SPACING_UNIT * 2}px`,
|
||||
}}
|
||||
>
|
||||
{getProfileImage(user.profileImageUrl)}
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const otherUserUnlockedAchievementCount = otherUser.achievements.filter(
|
||||
(achievement) => achievement.unlocked
|
||||
).length;
|
||||
const otherUserTotalAchievementCount = otherUser.achievements.length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
width: "100%",
|
||||
padding: `0 ${SPACING_UNIT * 2}px`,
|
||||
gap: `${SPACING_UNIT * 2}px`,
|
||||
}}
|
||||
>
|
||||
{getProfileImage(otherUser.profileImageUrl)}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<h1 style={{ fontSize: "1.2em", marginBottom: "8px" }}>
|
||||
{t("user_achievements", {
|
||||
displayName: otherUser.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>
|
||||
{otherUserUnlockedAchievementCount} /{" "}
|
||||
{otherUserTotalAchievementCount}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span>
|
||||
{formatDownloadProgress(
|
||||
otherUserUnlockedAchievementCount /
|
||||
otherUserTotalAchievementCount
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<progress
|
||||
max={1}
|
||||
value={
|
||||
otherUserUnlockedAchievementCount / otherUserTotalAchievementCount
|
||||
}
|
||||
className={styles.achievementsProgressBar}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "row-reverse",
|
||||
width: "100%",
|
||||
padding: `0 ${SPACING_UNIT * 2}px`,
|
||||
gap: `${SPACING_UNIT * 2}px`,
|
||||
}}
|
||||
>
|
||||
{getProfileImage(user.profileImageUrl)}
|
||||
<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>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AchievementList({
|
||||
achievements,
|
||||
otherUserAchievements,
|
||||
}: AchievementListProps) {
|
||||
const { t } = useTranslation("achievement");
|
||||
const { formatDateTime } = useDate();
|
||||
|
||||
if (!otherUserAchievements || otherUserAchievements.length === 0) {
|
||||
return (
|
||||
<ul className={styles.list}>
|
||||
{achievements.map((achievement, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className={styles.listItem}
|
||||
style={{ display: "flex" }}
|
||||
>
|
||||
<img
|
||||
className={styles.listItemImage({
|
||||
unlocked: achievement.unlocked,
|
||||
})}
|
||||
src={achievement.icon}
|
||||
alt={achievement.displayName}
|
||||
loading="lazy"
|
||||
/>
|
||||
<div style={{ flex: 1 }}>
|
||||
<h4>{achievement.displayName}</h4>
|
||||
<p>{achievement.description}</p>
|
||||
</div>
|
||||
{achievement.unlockTime && (
|
||||
<div style={{ whiteSpace: "nowrap" }}>
|
||||
<small>{t("unlocked_at")}</small>
|
||||
<p>{formatDateTime(achievement.unlockTime)}</p>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className={styles.list}>
|
||||
{otherUserAchievements.map((otherUserAchievement, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className={styles.listItem}
|
||||
style={{ display: "grid", gridTemplateColumns: "1fr auto 1fr" }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
className={styles.listItemImage({
|
||||
unlocked: otherUserAchievement.unlocked,
|
||||
})}
|
||||
src={otherUserAchievement.icon}
|
||||
alt={otherUserAchievement.displayName}
|
||||
loading="lazy"
|
||||
/>
|
||||
{otherUserAchievement.unlockTime && (
|
||||
<div style={{ whiteSpace: "nowrap" }}>
|
||||
<small>{t("unlocked_at")}</small>
|
||||
<p>{formatDateTime(otherUserAchievement.unlockTime)}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ textAlign: "center" }}>
|
||||
<h4>{otherUserAchievement.displayName}</h4>
|
||||
<p>{otherUserAchievement.description}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "row-reverse",
|
||||
alignItems: "center",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
textAlign: "right",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
className={styles.listItemImage({
|
||||
unlocked: achievements[index].unlocked,
|
||||
})}
|
||||
src={achievements[index].icon}
|
||||
alt={achievements[index].displayName}
|
||||
loading="lazy"
|
||||
/>
|
||||
{achievements[index].unlockTime && (
|
||||
<div style={{ whiteSpace: "nowrap" }}>
|
||||
<small>{t("unlocked_at")}</small>
|
||||
<p>{formatDateTime(achievements[index].unlockTime)}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export function AchievementsContent({ otherUser }: AchievementsContentProps) {
|
||||
const heroRef = useRef<HTMLDivElement | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [isHeaderStuck, setIsHeaderStuck] = useState(false);
|
||||
const [backdropOpactiy, setBackdropOpacity] = useState(1);
|
||||
|
||||
const { gameTitle, objectId, shop, achievements, gameColor, setGameColor } =
|
||||
useContext(gameDetailsContext);
|
||||
|
||||
const sortedAchievements = useMemo(() => {
|
||||
if (!otherUser || otherUser.achievements.length === 0) return achievements!;
|
||||
|
||||
return achievements!.sort((a, b) => {
|
||||
const indexA = otherUser.achievements.findIndex(
|
||||
(achievement) => achievement.name === a.name
|
||||
);
|
||||
const indexB = otherUser.achievements.findIndex(
|
||||
(achievement) => achievement.name === b.name
|
||||
);
|
||||
return indexA - indexB;
|
||||
});
|
||||
}, [achievements, otherUser]);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { userDetails } = useUserDetails();
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
const onScroll: React.UIEventHandler<HTMLElement> = (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 || !userDetails) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<img
|
||||
src={steamUrlBuilder.libraryHero(objectId)}
|
||||
alt={gameTitle}
|
||||
className={styles.hero}
|
||||
onLoad={handleHeroLoad}
|
||||
/>
|
||||
|
||||
<section
|
||||
ref={containerRef}
|
||||
onScroll={onScroll}
|
||||
className={styles.container}
|
||||
>
|
||||
<div ref={heroRef} className={styles.header}>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: gameColor,
|
||||
flex: 1,
|
||||
opacity: Math.min(1, 1 - backdropOpactiy),
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={styles.heroLogoBackdrop}
|
||||
style={{ opacity: backdropOpactiy }}
|
||||
>
|
||||
<div className={styles.heroContent}>
|
||||
<img
|
||||
src={steamUrlBuilder.logo(objectId)}
|
||||
className={styles.gameLogo}
|
||||
alt={gameTitle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.panel({ stuck: isHeaderStuck })}>
|
||||
<AchievementPanel
|
||||
user={{
|
||||
...userDetails,
|
||||
userId: userDetails.id,
|
||||
achievements: achievements!,
|
||||
}}
|
||||
otherUser={otherUser}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
width: "100%",
|
||||
backgroundColor: vars.color.background,
|
||||
}}
|
||||
>
|
||||
<AchievementList
|
||||
achievements={sortedAchievements}
|
||||
otherUserAchievements={otherUser?.achievements}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
13
src/renderer/src/pages/achievement/achievements-skeleton.tsx
Normal file
13
src/renderer/src/pages/achievement/achievements-skeleton.tsx
Normal file
|
@ -0,0 +1,13 @@
|
|||
import Skeleton from "react-loading-skeleton";
|
||||
import * as styles from "./achievements.css";
|
||||
|
||||
export function AchievementsSkeleton() {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.hero}>
|
||||
<Skeleton className={styles.heroImageSkeleton} />
|
||||
</div>
|
||||
<div className={styles.heroPanelSkeleton}></div>
|
||||
</div>
|
||||
);
|
||||
}
|
190
src/renderer/src/pages/achievement/achievements.css.ts
Normal file
190
src/renderer/src/pages/achievement/achievements.css.ts
Normal file
|
@ -0,0 +1,190 @@
|
|||
import { SPACING_UNIT, vars } from "../../theme.css";
|
||||
import { style } from "@vanilla-extract/css";
|
||||
import { recipe } from "@vanilla-extract/recipes";
|
||||
|
||||
export const HERO_HEIGHT = 300;
|
||||
|
||||
export const wrapper = style({
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
transition: "all ease 0.3s",
|
||||
});
|
||||
|
||||
export const header = style({
|
||||
display: "flex",
|
||||
height: `${HERO_HEIGHT}px`,
|
||||
minHeight: `${HERO_HEIGHT}px`,
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
flexDirection: "column",
|
||||
position: "relative",
|
||||
transition: "all ease 0.2s",
|
||||
"@media": {
|
||||
"(min-width: 1250px)": {
|
||||
height: "350px",
|
||||
minHeight: "350px",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const hero = style({
|
||||
position: "absolute",
|
||||
inset: "0",
|
||||
borderRadius: "4px",
|
||||
objectFit: "cover",
|
||||
cursor: "pointer",
|
||||
width: "100%",
|
||||
transition: "all ease 0.2s",
|
||||
});
|
||||
|
||||
export const heroContent = style({
|
||||
padding: `${SPACING_UNIT * 2}px`,
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-end",
|
||||
});
|
||||
|
||||
export const gameLogo = style({
|
||||
width: 300,
|
||||
});
|
||||
|
||||
export const container = style({
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "auto",
|
||||
zIndex: "1",
|
||||
});
|
||||
|
||||
export const panel = recipe({
|
||||
base: {
|
||||
width: "100%",
|
||||
height: "100px",
|
||||
minHeight: "100px",
|
||||
padding: `${SPACING_UNIT * 2}px 0`,
|
||||
backgroundColor: vars.color.darkBackground,
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
transition: "all ease 0.2s",
|
||||
borderBottom: `solid 1px ${vars.color.border}`,
|
||||
position: "sticky",
|
||||
overflow: "hidden",
|
||||
top: "0",
|
||||
zIndex: "1",
|
||||
},
|
||||
variants: {
|
||||
stuck: {
|
||||
true: {
|
||||
boxShadow: "0px 0px 15px 0px rgba(0, 0, 0, 0.8)",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const list = style({
|
||||
listStyle: "none",
|
||||
margin: "0",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: `${SPACING_UNIT * 2}px`,
|
||||
padding: `${SPACING_UNIT * 2}px`,
|
||||
width: "100%",
|
||||
});
|
||||
|
||||
export const listItem = style({
|
||||
transition: "all ease 0.1s",
|
||||
color: vars.color.muted,
|
||||
width: "100%",
|
||||
overflow: "hidden",
|
||||
borderRadius: "4px",
|
||||
padding: `${SPACING_UNIT}px ${SPACING_UNIT}px`,
|
||||
gap: `${SPACING_UNIT * 2}px`,
|
||||
alignItems: "center",
|
||||
textAlign: "left",
|
||||
":hover": {
|
||||
backgroundColor: "rgba(255, 255, 255, 0.15)",
|
||||
textDecoration: "none",
|
||||
},
|
||||
});
|
||||
|
||||
export const listItemImage = recipe({
|
||||
base: {
|
||||
width: "54px",
|
||||
height: "54px",
|
||||
borderRadius: "4px",
|
||||
objectFit: "cover",
|
||||
},
|
||||
variants: {
|
||||
unlocked: {
|
||||
false: {
|
||||
filter: "grayscale(100%)",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const achievementsProgressBar = style({
|
||||
width: "100%",
|
||||
height: "8px",
|
||||
transition: "all ease 0.2s",
|
||||
"::-webkit-progress-bar": {
|
||||
backgroundColor: "rgba(255, 255, 255, 0.15)",
|
||||
},
|
||||
"::-webkit-progress-value": {
|
||||
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`,
|
||||
});
|
||||
|
||||
export const profileAvatar = style({
|
||||
height: "65px",
|
||||
width: "65px",
|
||||
borderRadius: "4px",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
backgroundColor: vars.color.background,
|
||||
position: "relative",
|
||||
objectFit: "cover",
|
||||
});
|
|
@ -1,71 +1,93 @@
|
|||
import { useDate } from "@renderer/hooks";
|
||||
import { SPACING_UNIT } from "@renderer/theme.css";
|
||||
import { GameAchievement, GameShop } from "@types";
|
||||
import { setHeaderTitle } from "@renderer/features";
|
||||
import { useAppDispatch, useUserDetails } from "@renderer/hooks";
|
||||
import type { GameShop, UserAchievement } from "@types";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { vars } from "@renderer/theme.css";
|
||||
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();
|
||||
const objectId = searchParams.get("objectId");
|
||||
const shop = searchParams.get("shop");
|
||||
const title = searchParams.get("title");
|
||||
const userId = searchParams.get("userId");
|
||||
const displayName = searchParams.get("displayName");
|
||||
const profileImageUrl = searchParams.get("profileImageUrl");
|
||||
|
||||
const { format } = useDate();
|
||||
const { userDetails } = useUserDetails();
|
||||
|
||||
const [achievements, setAchievements] = useState<GameAchievement[]>([]);
|
||||
const [otherUserAchievements, setOtherUserAchievements] = useState<
|
||||
UserAchievement[] | null
|
||||
>(null);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
if (objectId && shop) {
|
||||
if (title) {
|
||||
dispatch(setHeaderTitle(title));
|
||||
}
|
||||
}, [dispatch, title]);
|
||||
|
||||
useEffect(() => {
|
||||
setOtherUserAchievements(null);
|
||||
if (userDetails?.id == userId) {
|
||||
setOtherUserAchievements([]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (objectId && shop && userId) {
|
||||
window.electron
|
||||
.getGameAchievements(objectId, shop as GameShop, userId || undefined)
|
||||
.getGameAchievements(objectId, shop as GameShop, userId)
|
||||
.then((achievements) => {
|
||||
setAchievements(achievements);
|
||||
setOtherUserAchievements(achievements);
|
||||
});
|
||||
}
|
||||
}, [objectId, shop, userId]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Achievement</h1>
|
||||
if (!objectId || !shop || !title) return null;
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
padding: `${SPACING_UNIT * 2}px`,
|
||||
const otherUserId = userDetails?.id === userId ? null : userId;
|
||||
|
||||
const otherUser = otherUserId
|
||||
? {
|
||||
userId: otherUserId,
|
||||
displayName: displayName || "",
|
||||
achievements: otherUserAchievements || [],
|
||||
profileImageUrl: profileImageUrl || "",
|
||||
}
|
||||
: null;
|
||||
|
||||
return (
|
||||
<GameDetailsContextProvider
|
||||
gameTitle={title}
|
||||
shop={shop as GameShop}
|
||||
objectId={objectId}
|
||||
>
|
||||
<GameDetailsContextConsumer>
|
||||
{({ isLoading, achievements }) => {
|
||||
return (
|
||||
<SkeletonTheme
|
||||
baseColor={vars.color.background}
|
||||
highlightColor="#444"
|
||||
>
|
||||
{isLoading ||
|
||||
achievements === null ||
|
||||
(otherUserId && otherUserAchievements === null) ? (
|
||||
<AchievementsSkeleton />
|
||||
) : (
|
||||
<AchievementsContent otherUser={otherUser} />
|
||||
)}
|
||||
</SkeletonTheme>
|
||||
);
|
||||
}}
|
||||
>
|
||||
{achievements.map((achievement, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
}}
|
||||
title={achievement.description}
|
||||
>
|
||||
<img
|
||||
style={{
|
||||
height: "60px",
|
||||
width: "60px",
|
||||
filter: achievement.unlocked ? "none" : "grayscale(100%)",
|
||||
}}
|
||||
src={
|
||||
achievement.unlocked ? achievement.icon : achievement.icongray
|
||||
}
|
||||
alt={achievement.displayName}
|
||||
loading="lazy"
|
||||
/>
|
||||
<div>
|
||||
<p>{achievement.displayName}</p>
|
||||
{achievement.unlockTime && format(achievement.unlockTime)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</GameDetailsContextConsumer>
|
||||
</GameDetailsContextProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -7,11 +7,11 @@ import type { GameRepack } from "@types";
|
|||
import * as styles from "./repacks-modal.css";
|
||||
|
||||
import { SPACING_UNIT } from "@renderer/theme.css";
|
||||
import { format } from "date-fns";
|
||||
import { DownloadSettingsModal } from "./download-settings-modal";
|
||||
import { gameDetailsContext } from "@renderer/context";
|
||||
import { Downloader } from "@shared";
|
||||
import { orderBy } from "lodash-es";
|
||||
import { useDate } from "@renderer/hooks";
|
||||
|
||||
export interface RepacksModalProps {
|
||||
visible: boolean;
|
||||
|
@ -36,6 +36,8 @@ export function RepacksModal({
|
|||
|
||||
const { t } = useTranslation("game_details");
|
||||
|
||||
const { formatDate } = useDate();
|
||||
|
||||
const sortedRepacks = useMemo(() => {
|
||||
return orderBy(repacks, (repack) => repack.uploadDate, "desc");
|
||||
}, [repacks]);
|
||||
|
@ -109,9 +111,7 @@ export function RepacksModal({
|
|||
|
||||
<p style={{ fontSize: "12px" }}>
|
||||
{repack.fileSize} - {repack.repacker} -{" "}
|
||||
{repack.uploadDate
|
||||
? format(repack.uploadDate, "dd/MM/yyyy")
|
||||
: ""}
|
||||
{repack.uploadDate ? formatDate(repack.uploadDate!) : ""}
|
||||
</p>
|
||||
</Button>
|
||||
);
|
||||
|
|
|
@ -29,6 +29,7 @@ export function SidebarSection({ title, children }: SidebarSectionProps) {
|
|||
maxHeight: isOpen ? `${content.current?.scrollHeight}px` : "0",
|
||||
overflow: "hidden",
|
||||
transition: "max-height 0.4s cubic-bezier(0, 1, 0, 1)",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { globalStyle, style } from "@vanilla-extract/css";
|
||||
|
||||
import { SPACING_UNIT, vars } from "../../../theme.css";
|
||||
import { recipe } from "@vanilla-extract/recipes";
|
||||
|
||||
export const contentSidebar = style({
|
||||
borderLeft: `solid 1px ${vars.color.border}`,
|
||||
|
@ -110,3 +111,46 @@ globalStyle(`${requirementsDetails} a`, {
|
|||
display: "flex",
|
||||
color: vars.color.body,
|
||||
});
|
||||
|
||||
export const list = style({
|
||||
listStyle: "none",
|
||||
margin: "0",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: `${SPACING_UNIT * 2}px`,
|
||||
padding: `${SPACING_UNIT * 2}px`,
|
||||
});
|
||||
|
||||
export const listItem = style({
|
||||
display: "flex",
|
||||
cursor: "pointer",
|
||||
transition: "all ease 0.1s",
|
||||
color: vars.color.muted,
|
||||
width: "100%",
|
||||
overflow: "hidden",
|
||||
borderRadius: "4px",
|
||||
padding: `${SPACING_UNIT}px ${SPACING_UNIT}px`,
|
||||
gap: `${SPACING_UNIT * 2}px`,
|
||||
alignItems: "center",
|
||||
textAlign: "left",
|
||||
":hover": {
|
||||
backgroundColor: "rgba(255, 255, 255, 0.15)",
|
||||
textDecoration: "none",
|
||||
},
|
||||
});
|
||||
|
||||
export const listItemImage = recipe({
|
||||
base: {
|
||||
width: "54px",
|
||||
height: "54px",
|
||||
borderRadius: "4px",
|
||||
objectFit: "cover",
|
||||
},
|
||||
variants: {
|
||||
unlocked: {
|
||||
false: {
|
||||
filter: "grayscale(100%)",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
@ -1,16 +1,49 @@
|
|||
import { useContext, useEffect, useState } from "react";
|
||||
import type { HowLongToBeatCategory, SteamAppDetails } from "@types";
|
||||
import type {
|
||||
HowLongToBeatCategory,
|
||||
SteamAppDetails,
|
||||
UserAchievement,
|
||||
} from "@types";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Link } from "@renderer/components";
|
||||
|
||||
import * as styles from "./sidebar.css";
|
||||
import { gameDetailsContext } from "@renderer/context";
|
||||
import { useDate, useFormat } from "@renderer/hooks";
|
||||
import { DownloadIcon, PeopleIcon } from "@primer/octicons-react";
|
||||
import { SPACING_UNIT } from "@renderer/theme.css";
|
||||
import { useDate, useFormat, useUserDetails } from "@renderer/hooks";
|
||||
import { DownloadIcon, LockIcon, PeopleIcon } from "@primer/octicons-react";
|
||||
import { HowLongToBeatSection } from "./how-long-to-beat-section";
|
||||
import { howLongToBeatEntriesTable } from "@renderer/dexie";
|
||||
import { SidebarSection } from "../sidebar-section/sidebar-section";
|
||||
import { buildGameAchievementPath } from "@renderer/helpers";
|
||||
import { SPACING_UNIT } from "@renderer/theme.css";
|
||||
|
||||
const fakeAchievements: UserAchievement[] = [
|
||||
{
|
||||
displayName: "Timber!!",
|
||||
name: "",
|
||||
hidden: false,
|
||||
description: "Chop down your first tree.",
|
||||
icon: "https://cdn.akamai.steamstatic.com/steamcommunity/public/images/apps/105600/0fbb33098c9da39d1d4771d8209afface9c46e81.jpg",
|
||||
unlocked: true,
|
||||
unlockTime: Date.now(),
|
||||
},
|
||||
{
|
||||
displayName: "Supreme Helper Minion!",
|
||||
name: "",
|
||||
hidden: false,
|
||||
icon: "https://cdn.akamai.steamstatic.com/steamcommunity/public/images/apps/105600/0a6ff6a36670c96ceb4d30cf6fd69d2fdf55f38e.jpg",
|
||||
unlocked: false,
|
||||
unlockTime: null,
|
||||
},
|
||||
{
|
||||
displayName: "Feast of Midas",
|
||||
name: "",
|
||||
hidden: false,
|
||||
icon: "https://cdn.akamai.steamstatic.com/steamcommunity/public/images/apps/105600/2d10311274fe7c92ab25cc29afdca86b019ad472.jpg",
|
||||
unlocked: false,
|
||||
unlockTime: null,
|
||||
},
|
||||
];
|
||||
|
||||
export function Sidebar() {
|
||||
const [howLongToBeat, setHowLongToBeat] = useState<{
|
||||
|
@ -18,6 +51,8 @@ export function Sidebar() {
|
|||
data: HowLongToBeatCategory[] | null;
|
||||
}>({ isLoading: true, data: null });
|
||||
|
||||
const { userDetails } = useUserDetails();
|
||||
|
||||
const [activeRequirement, setActiveRequirement] =
|
||||
useState<keyof SteamAppDetails["pc_requirements"]>("minimum");
|
||||
|
||||
|
@ -25,15 +60,10 @@ export function Sidebar() {
|
|||
useContext(gameDetailsContext);
|
||||
|
||||
const { t } = useTranslation("game_details");
|
||||
const { format } = useDate();
|
||||
const { formatDateTime } = useDate();
|
||||
|
||||
const { numberFormatter } = useFormat();
|
||||
|
||||
const buildGameAchievementPath = () => {
|
||||
const urlParams = new URLSearchParams({ objectId: objectId!, shop });
|
||||
return `/achievements?${urlParams.toString()}`;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (objectId) {
|
||||
setHowLongToBeat({ isLoading: true, data: null });
|
||||
|
@ -73,56 +103,99 @@ export function Sidebar() {
|
|||
|
||||
return (
|
||||
<aside className={styles.contentSidebar}>
|
||||
{achievements.length > 0 && (
|
||||
{userDetails === null && (
|
||||
<SidebarSection title={t("achievements")}>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
zIndex: 2,
|
||||
inset: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
background: "rgba(0, 0, 0, 0.7)",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
flexDirection: "column",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
}}
|
||||
>
|
||||
<LockIcon size={36} />
|
||||
<h3>{t("sign_in_to_see_achievements")}</h3>
|
||||
</div>
|
||||
<ul className={styles.list} style={{ filter: "blur(4px)" }}>
|
||||
{fakeAchievements.map((achievement, index) => (
|
||||
<li key={index}>
|
||||
<div className={styles.listItem}>
|
||||
<img
|
||||
style={{ filter: "blur(8px)" }}
|
||||
className={styles.listItemImage({
|
||||
unlocked: achievement.unlocked,
|
||||
})}
|
||||
src={achievement.icon}
|
||||
alt={achievement.displayName}
|
||||
/>
|
||||
<div>
|
||||
<p>{achievement.displayName}</p>
|
||||
<small>
|
||||
{achievement.unlockTime &&
|
||||
formatDateTime(achievement.unlockTime)}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</SidebarSection>
|
||||
)}
|
||||
{userDetails && achievements && achievements.length > 0 && (
|
||||
<SidebarSection
|
||||
title={t("achievements", {
|
||||
title={t("achievements_count", {
|
||||
unlockedCount: achievements.filter((a) => a.unlocked).length,
|
||||
achievementsCount: achievements.length,
|
||||
})}
|
||||
>
|
||||
<span>
|
||||
<Link to={buildGameAchievementPath()}>Ver todas</Link>
|
||||
</span>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
padding: `${SPACING_UNIT * 2}px`,
|
||||
}}
|
||||
>
|
||||
{achievements.slice(0, 6).map((achievement, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
}}
|
||||
title={achievement.description}
|
||||
>
|
||||
<img
|
||||
style={{
|
||||
height: "60px",
|
||||
width: "60px",
|
||||
filter: achievement.unlocked ? "none" : "grayscale(100%)",
|
||||
}}
|
||||
src={
|
||||
achievement.unlocked
|
||||
? achievement.icon
|
||||
: achievement.icongray
|
||||
}
|
||||
alt={achievement.displayName}
|
||||
loading="lazy"
|
||||
/>
|
||||
<div>
|
||||
<p>{achievement.displayName}</p>
|
||||
{achievement.unlockTime && format(achievement.unlockTime)}
|
||||
</div>
|
||||
</div>
|
||||
<ul className={styles.list}>
|
||||
{achievements.slice(0, 4).map((achievement, index) => (
|
||||
<li key={index}>
|
||||
<Link
|
||||
to={buildGameAchievementPath({
|
||||
shop: shop,
|
||||
objectId: objectId!,
|
||||
title: gameTitle,
|
||||
})}
|
||||
className={styles.listItem}
|
||||
title={achievement.description}
|
||||
>
|
||||
<img
|
||||
className={styles.listItemImage({
|
||||
unlocked: achievement.unlocked,
|
||||
})}
|
||||
src={achievement.icon}
|
||||
alt={achievement.displayName}
|
||||
/>
|
||||
<div>
|
||||
<p>{achievement.displayName}</p>
|
||||
<small>
|
||||
{achievement.unlockTime &&
|
||||
formatDateTime(achievement.unlockTime)}
|
||||
</small>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Link
|
||||
style={{ textAlign: "center" }}
|
||||
to={buildGameAchievementPath({
|
||||
shop: shop,
|
||||
objectId: objectId!,
|
||||
title: gameTitle,
|
||||
})}
|
||||
>
|
||||
{t("see_all_achievements")}
|
||||
</Link>
|
||||
</ul>
|
||||
</SidebarSection>
|
||||
)}
|
||||
|
||||
|
|
|
@ -16,6 +16,7 @@ import { FriendsBox } from "./friends-box";
|
|||
import { RecentGamesBox } from "./recent-games-box";
|
||||
import { UserGame } from "@types";
|
||||
import {
|
||||
buildGameAchievementPath,
|
||||
buildGameDetailsPath,
|
||||
formatDownloadProgress,
|
||||
} from "@renderer/helpers";
|
||||
|
@ -44,11 +45,24 @@ export function ProfileContent() {
|
|||
return userProfile?.relation?.status === "ACCEPTED";
|
||||
}, [userProfile]);
|
||||
|
||||
const buildUserGameDetailsPath = (game: UserGame) =>
|
||||
buildGameDetailsPath({
|
||||
...game,
|
||||
objectId: game.objectId,
|
||||
});
|
||||
const buildUserGameDetailsPath = (game: UserGame) => {
|
||||
if (!userProfile?.hasActiveSubscription) {
|
||||
return buildGameDetailsPath({
|
||||
...game,
|
||||
objectId: game.objectId,
|
||||
});
|
||||
}
|
||||
|
||||
const userParams = userProfile
|
||||
? {
|
||||
userId: userProfile.id,
|
||||
displayName: userProfile.displayName,
|
||||
profileImageUrl: userProfile.profileImageUrl,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return buildGameAchievementPath({ ...game }, userParams);
|
||||
};
|
||||
|
||||
const formatPlayTime = useCallback(
|
||||
(playTimeInSeconds = 0) => {
|
||||
|
@ -160,53 +174,55 @@ export function ProfileContent() {
|
|||
{formatPlayTime(game.playTimeInSeconds)}
|
||||
</small>
|
||||
|
||||
<div
|
||||
style={{
|
||||
color: "white",
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
{userProfile.hasActiveSubscription && (
|
||||
<div
|
||||
style={{
|
||||
color: "white",
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: 8,
|
||||
color: vars.color.muted,
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
justifyContent: "space-between",
|
||||
marginBottom: 8,
|
||||
color: vars.color.muted,
|
||||
}}
|
||||
>
|
||||
<TrophyIcon size={13} />
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<TrophyIcon size={13} />
|
||||
<span>
|
||||
{game.unlockedAchievementCount} /{" "}
|
||||
{game.achievementCount}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span>
|
||||
{game.unlockedAchievementCount} /{" "}
|
||||
{game.achievementCount}
|
||||
{formatDownloadProgress(
|
||||
game.unlockedAchievementCount /
|
||||
game.achievementCount
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span>
|
||||
{formatDownloadProgress(
|
||||
<progress
|
||||
max={1}
|
||||
value={
|
||||
game.unlockedAchievementCount /
|
||||
game.achievementCount
|
||||
)}
|
||||
</span>
|
||||
game.achievementCount
|
||||
}
|
||||
className={styles.achievementsProgressBar}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<progress
|
||||
max={1}
|
||||
value={
|
||||
game.unlockedAchievementCount /
|
||||
game.achievementCount
|
||||
}
|
||||
className={styles.achievementsProgressBar}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<img
|
||||
|
|
|
@ -30,6 +30,7 @@ export function SettingsGeneral() {
|
|||
downloadsPath: "",
|
||||
downloadNotificationsEnabled: false,
|
||||
repackUpdatesNotificationsEnabled: false,
|
||||
achievementNotificationsEnabled: false,
|
||||
language: "",
|
||||
});
|
||||
|
||||
|
@ -103,6 +104,8 @@ export function SettingsGeneral() {
|
|||
userPreferences.downloadNotificationsEnabled,
|
||||
repackUpdatesNotificationsEnabled:
|
||||
userPreferences.repackUpdatesNotificationsEnabled,
|
||||
achievementNotificationsEnabled:
|
||||
userPreferences.achievementNotificationsEnabled,
|
||||
language: language ?? "en",
|
||||
}));
|
||||
}
|
||||
|
@ -155,6 +158,17 @@ export function SettingsGeneral() {
|
|||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<CheckboxField
|
||||
label={t("enable_achievement_notifications")}
|
||||
checked={form.achievementNotificationsEnabled}
|
||||
onChange={() =>
|
||||
handleChange({
|
||||
achievementNotificationsEnabled:
|
||||
!form.achievementNotificationsEnabled,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue