Merge branch 'feature/game-achievements' of github.com:hydralauncher/hydra into feature/cloud-sync

This commit is contained in:
Chubby Granny Chaser 2024-10-20 18:16:02 +01:00
commit 2599b332fd
No known key found for this signature in database
26 changed files with 599 additions and 436 deletions

View file

@ -18,7 +18,6 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: 20.11.1
cache: "yarn"
- name: Install dependencies
run: yarn
@ -27,7 +26,6 @@ jobs:
uses: actions/setup-python@v5
with:
python-version: 3.9
cache: "pip"
- name: Install dependencies
run: pip install -r requirements.txt
@ -63,7 +61,6 @@ jobs:
with:
name: Build-${{ matrix.os }}
path: |
dist/win-unpacked/**
dist/*-portable.exe
dist/*.zip
dist/*.dmg

View file

@ -339,6 +339,7 @@
"achievement_unlocked": "Achievement unlocked",
"user_achievements": "{{displayName}}'s Achievements",
"your_achievements": "Your Achievements",
"unlocked_at": "Unlocked at:"
"unlocked_at": "Unlocked at:",
"subscription_needed": "A Hydra Cloud subscription is needed to see this content"
}
}

View file

@ -341,6 +341,7 @@
"achievement_unlocked": "Conquista desbloqueada",
"your_achievements": "Suas Conquistas",
"user_achievements": "Conquistas de {{displayName}}",
"unlocked_at": "Desbloqueado em:"
"unlocked_at": "Desbloqueado em:",
"subscription_needed": "Você precisa de uma assinatura Hydra Cloud para visualizar este conteúdo"
}
}

View file

@ -283,6 +283,7 @@
},
"achievement": {
"achievement_unlocked": "Conquista desbloqueada",
"unlocked_at": "Desbloqueado em:"
"unlocked_at": "Desbloqueado em:",
"subscription_needed": "Você precisa de uma assinatura Hydra Cloud para visualizar este conteúdo"
}
}

View file

@ -51,7 +51,7 @@ const getAchievementLocalUser = async (shop: string, objectId: string) => {
...achievementData,
unlocked: false,
unlockTime: null,
icon: icongray,
icongray: icongray,
} as UserAchievement;
})
.sort((a, b) => {
@ -110,7 +110,7 @@ const getAchievementsRemoteUser = async (
...achievementData,
unlocked: false,
unlockTime: null,
icon: icongray,
icongray: icongray,
} as UserAchievement;
})
.sort((a, b) => {

View file

@ -50,6 +50,7 @@ import "./user/unblock-user";
import "./user/get-user-friends";
import "./user/get-user-stats";
import "./user/report-user";
import "./user/get-compared-unlocked-achievements";
import "./profile/get-friend-requests";
import "./profile/get-me";
import "./profile/undo-friendship";

View file

@ -0,0 +1,44 @@
import type { ComparedAchievements, GameShop } from "@types";
import { registerEvent } from "../register-event";
import { userPreferencesRepository } from "@main/repository";
import { HydraApi } from "@main/services";
const getComparedUnlockedAchievements = async (
_event: Electron.IpcMainInvokeEvent,
objectId: string,
shop: GameShop,
userId: string
) => {
const userPreferences = await userPreferencesRepository.findOne({
where: { id: 1 },
});
return HydraApi.get<ComparedAchievements>(
`/users/${userId}/games/achievements/compare`,
{
shop,
objectId,
language: userPreferences?.language || "en",
}
).then((achievements) => {
const sortedAchievements = achievements.achievements.sort((a, b) => {
if (a.otherUserStat.unlocked && !b.otherUserStat.unlocked) return -1;
if (!a.otherUserStat.unlocked && b.otherUserStat.unlocked) return 1;
if (a.otherUserStat.unlocked && b.otherUserStat.unlocked) {
return b.otherUserStat.unlockTime! - a.otherUserStat.unlockTime!;
}
return Number(a.hidden) - Number(b.hidden);
});
return {
...achievements,
achievements: sortedAchievements,
} as ComparedAchievements;
});
};
registerEvent(
"getComparedUnlockedAchievements",
getComparedUnlockedAchievements
);

View file

@ -9,7 +9,7 @@ import {
getAlternativeObjectIds,
} from "./find-achivement-files";
import type { AchievementFile } from "@types";
import { achievementsLogger, logger } from "../logger";
import { achievementsLogger } from "../logger";
import { Cracker } from "@shared";
const fileStats: Map<string, number> = new Map();
@ -55,8 +55,6 @@ const processAchievementFileDiff = async (
) => {
const unlockedAchievements = parseAchievementFile(file.filePath, file.type);
logger.log("Achievements from file", file.filePath, unlockedAchievements);
if (unlockedAchievements.length) {
return mergeAchievements(
game.objectID,
@ -80,7 +78,7 @@ const compareFltFolder = async (game: Game, file: AchievementFile) => {
return;
}
logger.log("Detected change in FLT folder", file.filePath);
achievementsLogger.log("Detected change in FLT folder", file.filePath);
await processAchievementFileDiff(game, file);
} catch (err) {
achievementsLogger.error(err);
@ -101,6 +99,13 @@ const compareFile = async (game: Game, file: AchievementFile) => {
if (!previousStat) {
if (currentStat.mtimeMs) {
achievementsLogger.log(
"First change in file",
file.filePath,
previousStat,
currentStat.mtimeMs
);
await processAchievementFileDiff(game, file);
return;
}
@ -110,7 +115,7 @@ const compareFile = async (game: Game, file: AchievementFile) => {
return;
}
logger.log(
achievementsLogger.log(
"Detected change in file",
file.filePath,
previousStat,

View file

@ -42,7 +42,9 @@ export const getGameAchievementData = async (
where: { objectId, shop },
})
.then((gameAchievements) => {
return JSON.parse(gameAchievements?.achievements || "[]");
return JSON.parse(
gameAchievements?.achievements || "[]"
) as AchievementData[];
});
});
};

View file

@ -64,7 +64,13 @@ export const mergeAchievements = async (
localGameAchievement?.unlockedAchievements || "[]"
).filter((achievement) => achievement.name) as UnlockedAchievement[];
const newAchievements = achievements
const newAchievementsMap = new Map(
achievements.reverse().map((achievement) => {
return [achievement.name.toUpperCase(), achievement];
})
);
const newAchievements = [...newAchievementsMap.values()]
.filter((achievement) => {
return !unlockedAchievements.some((localAchievement) => {
return (
@ -114,7 +120,7 @@ export const mergeAchievements = async (
const mergedLocalAchievements = unlockedAchievements.concat(newAchievements);
if (game?.remoteId) {
if (game.remoteId) {
return HydraApi.put(
"/profile/games/achievements",
{

View file

@ -242,15 +242,23 @@ const processRld = (unlockedAchievements: any): UnlockedAchievement[] => {
const unlockedAchievement = unlockedAchievements[achievement];
if (unlockedAchievement?.State) {
newUnlockedAchievements.push({
name: achievement,
unlockTime:
new DataView(
new Uint8Array(
Buffer.from(unlockedAchievement.Time.toString(), "hex")
).buffer
).getUint32(0, true) * 1000,
});
const unlocked = new DataView(
new Uint8Array(
Buffer.from(unlockedAchievement.State.toString(), "hex")
).buffer
).getUint32(0, true);
if (unlocked === 1) {
newUnlockedAchievements.push({
name: achievement,
unlockTime:
new DataView(
new Uint8Array(
Buffer.from(unlockedAchievement.Time.toString(), "hex")
).buffer
).getUint32(0, true) * 1000,
});
}
}
}

View file

@ -49,14 +49,14 @@ export const updateAllLocalUnlockedAchievements = async () => {
if (parsedAchievements.length) {
unlockedAchievements.push(...parsedAchievements);
}
achievementsLogger.log(
"Achievement file for",
game.title,
achievementFile.filePath,
parsedAchievements
);
achievementsLogger.log(
"Achievement file for",
game.title,
achievementFile.filePath,
parsedAchievements
);
}
}
mergeAchievements(game.objectID, "steam", unlockedAchievements, false);

View file

@ -80,6 +80,10 @@ export class WindowManager {
this.mainWindow.webContents.session.webRequest.onBeforeSendHeaders(
(details, callback) => {
if (details.webContentsId !== this.mainWindow?.webContents.id) {
return callback(details);
}
const userAgent = new UserAgent();
callback({

View file

@ -259,6 +259,17 @@ contextBridge.exposeInMainWorld("electron", {
getUserStats: (userId: string) => ipcRenderer.invoke("getUserStats", userId),
reportUser: (userId: string, reason: string, description: string) =>
ipcRenderer.invoke("reportUser", userId, reason, description),
getComparedUnlockedAchievements: (
objectId: string,
shop: GameShop,
userId: string
) =>
ipcRenderer.invoke(
"getComparedUnlockedAchievements",
objectId,
shop,
userId
),
/* Auth */
signOut: () => ipcRenderer.invoke("signOut"),

View file

@ -29,6 +29,7 @@ import type {
GameArtifact,
LudusaviBackup,
UserAchievement,
ComparedAchievements,
} from "@types";
import type { AxiosProgressEvent } from "axios";
import type { DiskSpace } from "check-disk-space";
@ -202,6 +203,11 @@ declare global {
reason: string,
description: string
) => Promise<void>;
getComparedUnlockedAchievements: (
objectId: string,
shop: GameShop,
userId: string
) => Promise<ComparedAchievements>;
/* Profile */
getMe: () => Promise<UserDetails | null>;

View file

@ -36,15 +36,13 @@ export const buildGameDetailsPath = (
export const buildGameAchievementPath = (
game: { shop: GameShop; objectId: string; title: string },
user?: { userId: string; displayName: string; profileImageUrl: string | null }
user?: { userId: string }
) => {
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()}`;

View file

@ -72,7 +72,7 @@ export function useDate() {
const locale = getDateLocale();
return format(
date,
locale == enUS ? "MM/dd/yyyy - HH:mm" : "dd/MM/yyyy - HH:mm"
locale == enUS ? "MM/dd/yyyy - HH:mm" : "dd/MM/yyyy HH:mm"
);
},

View file

@ -1,4 +1,4 @@
import { useCallback } from "react";
import { useCallback, useMemo } from "react";
import { useAppDispatch, useAppSelector } from "./redux";
import {
setProfileBackground,
@ -129,7 +129,16 @@ export function useUserDetails() {
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 {
userDetails,

View file

@ -1,48 +1,57 @@
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 { useContext, useEffect, 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 {
buildGameDetailsPath,
formatDownloadProgress,
} from "@renderer/helpers";
import { LockIcon, PersonIcon, TrophyIcon } from "@primer/octicons-react";
import { SPACING_UNIT, vars } from "@renderer/theme.css";
import { gameDetailsContext } from "@renderer/context";
import { UserAchievement } from "@types";
import { ComparedAchievements, UserAchievement } from "@types";
import { average } from "color.js";
import Color from "color";
const HERO_ANIMATION_THRESHOLD = 25;
import { Link } from "@renderer/components";
import { ComparedAchievementList } from "./compared-achievement-list";
interface UserInfo {
userId: string;
displayName: string;
achievements: UserAchievement[];
profileImageUrl: string | null;
totalAchievementCount: number;
unlockedAchievementCount: number;
}
interface AchievementsContentProps {
otherUser: UserInfo | null;
comparedAchievements: ComparedAchievements | null;
}
interface AchievementListProps {
achievements: UserAchievement[];
otherUserAchievements?: UserAchievement[];
}
interface AchievementPanelProps {
interface AchievementSummaryProps {
user: UserInfo;
otherUser: UserInfo | null;
isComparison?: boolean;
}
function AchievementPanel({ user, otherUser }: AchievementPanelProps) {
function AchievementSummary({ user, isComparison }: AchievementSummaryProps) {
const { t } = useTranslation("achievement");
const { userDetails, hasActiveSubscription } = useUserDetails();
const getProfileImage = (imageUrl: string | null | undefined) => {
const getProfileImage = (user: UserInfo) => {
return (
<div className={styles.profileAvatar}>
{imageUrl ? (
<img className={styles.profileAvatar} src={imageUrl} alt={"teste"} />
{user.profileImageUrl ? (
<img
className={styles.profileAvatar}
src={user.profileImageUrl}
alt={user.displayName}
/>
) : (
<PersonIcon size={24} />
)}
@ -50,331 +59,160 @@ function AchievementPanel({ user, otherUser }: AchievementPanelProps) {
);
};
const userTotalAchievementCount = user.achievements.length;
const userUnlockedAchievementCount = user.achievements.filter(
(achievement) => achievement.unlocked
).length;
if (!otherUser) {
if (
isComparison &&
userDetails?.id == user.userId &&
!hasActiveSubscription
) {
return (
<div
style={{
display: "flex",
flexDirection: "row",
width: "100%",
padding: `0 ${SPACING_UNIT * 2}px`,
gap: `${SPACING_UNIT * 2}px`,
alignItems: "center",
position: "relative",
padding: `${SPACING_UNIT}px`,
}}
>
{getProfileImage(user.profileImageUrl)}
<div
style={{
position: "absolute",
zIndex: 2,
inset: 0,
width: "100%",
height: "100%",
background: "rgba(0, 0, 0, 0.7)",
display: "flex",
alignItems: "center",
flexDirection: "row",
gap: `${SPACING_UNIT}px`,
borderRadius: "4px",
justifyContent: "center",
}}
>
<LockIcon size={24} />
<h3>
<Link to={""}>{t("subscription_needed")}</Link>
</h3>
</div>
<div
style={{
display: "flex",
flexDirection: "column",
width: "100%",
gap: `${SPACING_UNIT * 2}px`,
alignItems: "center",
height: "62px",
position: "relative",
filter: "blur(4px)",
}}
>
<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}
/>
{getProfileImage(user)}
<h1 style={{ marginBottom: "8px" }}>{user.displayName}</h1>
</div>
</div>
);
}
const otherUserUnlockedAchievementCount = otherUser.achievements.filter(
(achievement) => achievement.unlocked
).length;
const otherUserTotalAchievementCount = otherUser.achievements.length;
return (
<>
<div
style={{
display: "flex",
gap: `${SPACING_UNIT * 2}px`,
alignItems: "center",
padding: `${SPACING_UNIT}px`,
}}
>
{getProfileImage(user)}
<div
style={{
display: "flex",
flexDirection: "row",
flexDirection: "column",
width: "100%",
padding: `0 ${SPACING_UNIT * 2}px`,
gap: `${SPACING_UNIT * 2}px`,
}}
>
{getProfileImage(otherUser.profileImageUrl)}
<h1 style={{ marginBottom: "8px" }}>{user.displayName}</h1>
<div
style={{
display: "flex",
flexDirection: "column",
width: "100%",
justifyContent: "space-between",
marginBottom: 8,
color: vars.color.muted,
}}
>
<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,
alignItems: "center",
gap: 8,
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
}}
>
<TrophyIcon size={13} />
<span>
{otherUserUnlockedAchievementCount} /{" "}
{otherUserTotalAchievementCount}
</span>
</div>
<TrophyIcon size={13} />
<span>
{formatDownloadProgress(
otherUserUnlockedAchievementCount /
otherUserTotalAchievementCount
)}
{user.unlockedAchievementCount} / {user.totalAchievementCount}
</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}
/>
<span>
{formatDownloadProgress(
user.unlockedAchievementCount / user.totalAchievementCount
)}
</span>
</div>
<progress
max={1}
value={user.unlockedAchievementCount / user.totalAchievementCount}
className={styles.achievementsProgressBar}
/>
</div>
</>
</div>
);
}
function AchievementList({
achievements,
otherUserAchievements,
}: AchievementListProps) {
function AchievementList({ achievements }: 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>
)}
{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>
);
}
export function AchievementsContent({ otherUser }: AchievementsContentProps) {
export function AchievementsContent({
otherUser,
comparedAchievements,
}: 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();
const { userDetails, hasActiveSubscription } = useUserDetails();
useEffect(() => {
if (gameTitle) {
@ -399,11 +237,6 @@ export function AchievementsContent({ otherUser }: AchievementsContentProps) {
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);
}
@ -411,8 +244,25 @@ export function AchievementsContent({ otherUser }: AchievementsContentProps) {
if (scrollY <= heroHeight && isHeaderStuck) {
setIsHeaderStuck(false);
}
};
setBackdropOpacity(opacity);
const getProfileImage = (
profileImageUrl: string | null,
displayName: string
) => {
return (
<div className={styles.profileAvatarSmall}>
{profileImageUrl ? (
<img
className={styles.profileAvatarSmall}
src={profileImageUrl}
alt={displayName}
/>
) : (
<PersonIcon size={24} />
)}
</div>
);
};
if (!objectId || !shop || !gameTitle || !userDetails) return null;
@ -421,8 +271,9 @@ export function AchievementsContent({ otherUser }: AchievementsContentProps) {
<div className={styles.wrapper}>
<img
src={steamUrlBuilder.libraryHero(objectId)}
style={{ display: "none" }}
alt={gameTitle}
className={styles.hero}
className={styles.heroImage}
onLoad={handleHeroLoad}
/>
@ -431,39 +282,86 @@ export function AchievementsContent({ otherUser }: AchievementsContentProps) {
onScroll={onScroll}
className={styles.container}
>
<div ref={heroRef} className={styles.header}>
<div
style={{
backgroundColor: gameColor,
flex: 1,
opacity: Math.min(1, 1 - backdropOpactiy),
}}
/>
<div
style={{
display: "flex",
flexDirection: "column",
background: `linear-gradient(0deg, ${vars.color.darkBackground} 0%, ${gameColor} 100%)`,
}}
>
<div ref={heroRef} className={styles.hero}>
<div className={styles.heroContent}>
<Link
to={buildGameDetailsPath({ shop, objectId, title: gameTitle })}
>
<img
src={steamUrlBuilder.logo(objectId)}
className={styles.gameLogo}
alt={gameTitle}
/>
</Link>
</div>
</div>
<div
className={styles.heroLogoBackdrop}
style={{ opacity: backdropOpactiy }}
style={{
display: "flex",
flexDirection: "column",
width: "100%",
gap: `${SPACING_UNIT}px`,
padding: `${SPACING_UNIT}px`,
}}
>
<div className={styles.heroContent}>
<img
src={steamUrlBuilder.logo(objectId)}
className={styles.gameLogo}
alt={gameTitle}
/>
</div>
<AchievementSummary
user={{
...userDetails,
userId: userDetails.id,
totalAchievementCount: comparedAchievements
? comparedAchievements.ownerUser.totalAchievementCount
: achievements!.length,
unlockedAchievementCount: comparedAchievements
? comparedAchievements.ownerUser.unlockedAchievementCount
: achievements!.filter((achievement) => achievement.unlocked)
.length,
}}
isComparison={otherUser !== null}
/>
{otherUser && <AchievementSummary user={otherUser} />}
</div>
</div>
<div className={styles.panel({ stuck: isHeaderStuck })}>
<AchievementPanel
user={{
...userDetails,
userId: userDetails.id,
achievements: achievements!,
}}
otherUser={otherUser}
/>
</div>
{otherUser && (
<div className={styles.tableHeader({ stuck: isHeaderStuck })}>
<div
style={{
display: "grid",
gridTemplateColumns: hasActiveSubscription
? "3fr 1fr 1fr"
: "3fr 2fr",
gap: `${SPACING_UNIT * 2}px`,
padding: `${SPACING_UNIT}px ${SPACING_UNIT * 3}px`,
}}
>
<div></div>
{hasActiveSubscription && (
<div style={{ display: "flex", justifyContent: "center" }}>
{getProfileImage(
userDetails.profileImageUrl,
userDetails.displayName
)}
</div>
)}
<div style={{ display: "flex", justifyContent: "center" }}>
{getProfileImage(
otherUser.profileImageUrl,
otherUser.displayName
)}
</div>
</div>
</div>
)}
<div
style={{
display: "flex",
@ -472,10 +370,11 @@ export function AchievementsContent({ otherUser }: AchievementsContentProps) {
backgroundColor: vars.color.background,
}}
>
<AchievementList
achievements={sortedAchievements}
otherUserAchievements={otherUser?.achievements}
/>
{otherUser ? (
<ComparedAchievementList achievements={comparedAchievements!} />
) : (
<AchievementList achievements={achievements!} />
)}
</div>
</section>
</div>

View file

@ -4,7 +4,7 @@ import * as styles from "./achievements.css";
export function AchievementsSkeleton() {
return (
<div className={styles.container}>
<div className={styles.hero}>
<div className={styles.heroImage}>
<Skeleton className={styles.heroImageSkeleton} />
</div>
<div className={styles.heroPanelSkeleton}></div>

View file

@ -2,7 +2,9 @@ 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 HERO_HEIGHT = 150;
export const LOGO_HEIGHT = 100;
export const LOGO_MAX_WIDTH = 200;
export const wrapper = style({
display: "flex",
@ -13,11 +15,11 @@ export const wrapper = style({
transition: "all ease 0.3s",
});
export const header = style({
display: "flex",
export const hero = style({
width: "100%",
height: `${HERO_HEIGHT}px`,
minHeight: `${HERO_HEIGHT}px`,
gap: `${SPACING_UNIT}px`,
display: "flex",
flexDirection: "column",
position: "relative",
transition: "all ease 0.2s",
@ -29,27 +31,41 @@ export const header = style({
},
});
export const hero = style({
position: "absolute",
inset: "0",
borderRadius: "4px",
objectFit: "cover",
cursor: "pointer",
export const heroImage = style({
width: "100%",
height: `${HERO_HEIGHT}px`,
minHeight: `${HERO_HEIGHT}px`,
objectFit: "cover",
objectPosition: "top",
transition: "all ease 0.2s",
position: "absolute",
zIndex: "0",
filter: "blur(5px)",
"@media": {
"(min-width: 1250px)": {
objectPosition: "center",
height: "350px",
minHeight: "350px",
},
},
});
export const heroContent = style({
padding: `${SPACING_UNIT * 2}px`,
height: "100%",
width: "100%",
display: "flex",
justifyContent: "space-between",
alignItems: "flex-end",
alignItems: "center",
});
export const gameLogo = style({
width: 300,
width: LOGO_MAX_WIDTH,
height: LOGO_HEIGHT,
objectFit: "contain",
transition: "all ease 0.2s",
":hover": {
transform: "scale(1.05)",
},
});
export const container = style({
@ -61,19 +77,13 @@ export const container = style({
zIndex: "1",
});
export const panel = recipe({
export const tableHeader = 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",
},
@ -145,7 +155,6 @@ export const achievementsProgressBar = style({
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",
@ -180,8 +189,20 @@ export const listItemSkeleton = style({
});
export const profileAvatar = style({
height: "65px",
width: "65px",
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",
width: "32px",
borderRadius: "4px",
display: "flex",
justifyContent: "center",

View file

@ -1,7 +1,7 @@
import { setHeaderTitle } from "@renderer/features";
import { useAppDispatch, useUserDetails } from "@renderer/hooks";
import type { GameShop, UserAchievement } from "@types";
import { useEffect, useState } from "react";
import type { ComparedAchievements, GameShop } from "@types";
import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { vars } from "@renderer/theme.css";
import {
@ -18,14 +18,11 @@ export default function Achievements() {
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 { userDetails } = useUserDetails();
const [otherUserAchievements, setOtherUserAchievements] = useState<
UserAchievement[] | null
>(null);
const [comparedAchievements, setComparedAchievements] =
useState<ComparedAchievements | null>(null);
const dispatch = useAppDispatch();
@ -36,31 +33,34 @@ export default function Achievements() {
}, [dispatch, title]);
useEffect(() => {
setOtherUserAchievements(null);
setComparedAchievements(null);
if (userDetails?.id == userId) {
setOtherUserAchievements([]);
return;
}
if (objectId && shop && userId) {
window.electron
.getGameAchievements(objectId, shop as GameShop, userId)
.then((achievements) => {
setOtherUserAchievements(achievements);
});
.getComparedUnlockedAchievements(objectId, shop as GameShop, userId)
.then(setComparedAchievements);
}
}, [objectId, shop, userId]);
const otherUserId = userDetails?.id === userId ? null : userId;
const otherUser = otherUserId
? {
userId: otherUserId,
displayName: displayName || "",
achievements: otherUserAchievements || [],
profileImageUrl: profileImageUrl || "",
}
: null;
const otherUser = useMemo(() => {
if (!otherUserId || !comparedAchievements) return null;
return {
userId: otherUserId,
displayName: comparedAchievements.otherUser.displayName,
profileImageUrl: comparedAchievements.otherUser.profileImageUrl,
totalAchievementCount:
comparedAchievements.otherUser.totalAchievementCount,
unlockedAchievementCount:
comparedAchievements.otherUser.unlockedAchievementCount,
};
}, [otherUserId, comparedAchievements]);
return (
<GameDetailsContextProvider
@ -70,17 +70,23 @@ export default function Achievements() {
>
<GameDetailsContextConsumer>
{({ isLoading, achievements }) => {
const showSkeleton =
isLoading ||
achievements === null ||
(otherUserId && comparedAchievements === null);
return (
<SkeletonTheme
baseColor={vars.color.background}
highlightColor="#444"
>
{isLoading ||
achievements === null ||
(otherUserId && otherUserAchievements === null) ? (
{showSkeleton ? (
<AchievementsSkeleton />
) : (
<AchievementsContent otherUser={otherUser} />
<AchievementsContent
otherUser={otherUser}
comparedAchievements={comparedAchievements!}
/>
)}
</SkeletonTheme>
);

View file

@ -0,0 +1,110 @@
import type { ComparedAchievements } from "@types";
import * as styles from "./achievements.css";
import { CheckCircleIcon, LockIcon } from "@primer/octicons-react";
import { useDate } from "@renderer/hooks";
import { SPACING_UNIT } from "@renderer/theme.css";
export interface ComparedAchievementListProps {
achievements: ComparedAchievements;
}
export function ComparedAchievementList({
achievements,
}: ComparedAchievementListProps) {
const { formatDateTime } = useDate();
return (
<ul className={styles.list}>
{achievements.achievements.map((achievement, index) => (
<li
key={index}
className={styles.listItem}
style={{
display: "grid",
gridTemplateColumns: achievement.onwerUserStat
? "3fr 1fr 1fr"
: "3fr 2fr",
}}
>
<div
style={{
display: "flex",
flexDirection: "row",
alignItems: "center",
gap: `${SPACING_UNIT}px`,
}}
>
<img
className={styles.listItemImage({
unlocked: true,
})}
src={achievement.icon}
alt={achievement.displayName}
loading="lazy"
/>
<div>
<h4>{achievement.displayName}</h4>
<p>{achievement.description}</p>
</div>
</div>
{achievement.onwerUserStat ? (
achievement.onwerUserStat.unlocked ? (
<div
style={{
whiteSpace: "nowrap",
display: "flex",
flexDirection: "row",
gap: `${SPACING_UNIT}px`,
justifyContent: "center",
}}
>
<CheckCircleIcon />
<small>
{formatDateTime(achievement.onwerUserStat.unlockTime!)}
</small>
</div>
) : (
<div
style={{
display: "flex",
padding: `${SPACING_UNIT}px`,
justifyContent: "center",
}}
>
<LockIcon />
</div>
)
) : null}
{achievement.otherUserStat.unlocked ? (
<div
style={{
whiteSpace: "nowrap",
display: "flex",
flexDirection: "row",
gap: `${SPACING_UNIT}px`,
justifyContent: "center",
}}
>
<CheckCircleIcon />
<small>
{formatDateTime(achievement.otherUserStat.unlockTime!)}
</small>
</div>
) : (
<div
style={{
display: "flex",
padding: `${SPACING_UNIT}px`,
justifyContent: "center",
}}
>
<LockIcon />
</div>
)}
</li>
))}
</ul>
);
}

View file

@ -24,6 +24,8 @@ const fakeAchievements: UserAchievement[] = [
hidden: false,
description: "Chop down your first tree.",
icon: "https://cdn.akamai.steamstatic.com/steamcommunity/public/images/apps/105600/0fbb33098c9da39d1d4771d8209afface9c46e81.jpg",
icongray:
"https://cdn.akamai.steamstatic.com/steamcommunity/public/images/apps/105600/0fbb33098c9da39d1d4771d8209afface9c46e81.jpg",
unlocked: true,
unlockTime: Date.now(),
},
@ -32,6 +34,8 @@ const fakeAchievements: UserAchievement[] = [
name: "",
hidden: false,
icon: "https://cdn.akamai.steamstatic.com/steamcommunity/public/images/apps/105600/0a6ff6a36670c96ceb4d30cf6fd69d2fdf55f38e.jpg",
icongray:
"https://cdn.akamai.steamstatic.com/steamcommunity/public/images/apps/105600/0a6ff6a36670c96ceb4d30cf6fd69d2fdf55f38e.jpg",
unlocked: false,
unlockTime: null,
},
@ -40,6 +44,8 @@ const fakeAchievements: UserAchievement[] = [
name: "",
hidden: false,
icon: "https://cdn.akamai.steamstatic.com/steamcommunity/public/images/apps/105600/2d10311274fe7c92ab25cc29afdca86b019ad472.jpg",
icongray:
"https://cdn.akamai.steamstatic.com/steamcommunity/public/images/apps/105600/2d10311274fe7c92ab25cc29afdca86b019ad472.jpg",
unlocked: false,
unlockTime: null,
},

View file

@ -46,7 +46,7 @@ export function ProfileContent() {
}, [userProfile]);
const buildUserGameDetailsPath = (game: UserGame) => {
if (!userProfile?.hasActiveSubscription) {
if (!userProfile?.hasActiveSubscription || game.achievementCount === 0) {
return buildGameDetailsPath({
...game,
objectId: game.objectId,
@ -56,8 +56,6 @@ export function ProfileContent() {
const userParams = userProfile
? {
userId: userProfile.id,
displayName: userProfile.displayName,
profileImageUrl: userProfile.profileImageUrl,
}
: undefined;
@ -174,55 +172,56 @@ export function ProfileContent() {
{formatPlayTime(game.playTimeInSeconds)}
</small>
{userProfile.hasActiveSubscription && (
<div
style={{
color: "white",
width: "100%",
display: "flex",
flexDirection: "column",
}}
>
{userProfile.hasActiveSubscription &&
game.achievementCount > 0 && (
<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

View file

@ -45,6 +45,7 @@ export interface UserAchievement {
unlocked: boolean;
unlockTime: number | null;
icon: string;
icongray: string;
}
export interface RemoteUnlockedAchievement {
@ -341,6 +342,33 @@ export interface GameArtifact {
downloadCount: number;
}
export interface ComparedAchievements {
ownerUser: {
totalAchievementCount: number;
unlockedAchievementCount: number;
};
otherUser: {
displayName: string;
profileImageUrl: string;
totalAchievementCount: number;
unlockedAchievementCount: number;
};
achievements: {
hidden: boolean;
icon: string;
displayName: string;
description: string;
onwerUserStat?: {
unlocked: boolean;
unlockTime: number;
};
otherUserStat: {
unlocked: boolean;
unlockTime: number;
};
}[];
}
export * from "./steam.types";
export * from "./real-debrid.types";
export * from "./ludusavi.types";