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
2599b332fd
26 changed files with 599 additions and 436 deletions
3
.github/workflows/build.yml
vendored
3
.github/workflows/build.yml
vendored
|
@ -18,7 +18,6 @@ jobs:
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20.11.1
|
node-version: 20.11.1
|
||||||
cache: "yarn"
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: yarn
|
run: yarn
|
||||||
|
@ -27,7 +26,6 @@ jobs:
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: 3.9
|
python-version: 3.9
|
||||||
cache: "pip"
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pip install -r requirements.txt
|
run: pip install -r requirements.txt
|
||||||
|
@ -63,7 +61,6 @@ jobs:
|
||||||
with:
|
with:
|
||||||
name: Build-${{ matrix.os }}
|
name: Build-${{ matrix.os }}
|
||||||
path: |
|
path: |
|
||||||
dist/win-unpacked/**
|
|
||||||
dist/*-portable.exe
|
dist/*-portable.exe
|
||||||
dist/*.zip
|
dist/*.zip
|
||||||
dist/*.dmg
|
dist/*.dmg
|
||||||
|
|
|
@ -339,6 +339,7 @@
|
||||||
"achievement_unlocked": "Achievement unlocked",
|
"achievement_unlocked": "Achievement unlocked",
|
||||||
"user_achievements": "{{displayName}}'s Achievements",
|
"user_achievements": "{{displayName}}'s Achievements",
|
||||||
"your_achievements": "Your 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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -341,6 +341,7 @@
|
||||||
"achievement_unlocked": "Conquista desbloqueada",
|
"achievement_unlocked": "Conquista desbloqueada",
|
||||||
"your_achievements": "Suas Conquistas",
|
"your_achievements": "Suas Conquistas",
|
||||||
"user_achievements": "Conquistas de {{displayName}}",
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -283,6 +283,7 @@
|
||||||
},
|
},
|
||||||
"achievement": {
|
"achievement": {
|
||||||
"achievement_unlocked": "Conquista desbloqueada",
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -51,7 +51,7 @@ const getAchievementLocalUser = async (shop: string, objectId: string) => {
|
||||||
...achievementData,
|
...achievementData,
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
unlockTime: null,
|
unlockTime: null,
|
||||||
icon: icongray,
|
icongray: icongray,
|
||||||
} as UserAchievement;
|
} as UserAchievement;
|
||||||
})
|
})
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
|
@ -110,7 +110,7 @@ const getAchievementsRemoteUser = async (
|
||||||
...achievementData,
|
...achievementData,
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
unlockTime: null,
|
unlockTime: null,
|
||||||
icon: icongray,
|
icongray: icongray,
|
||||||
} as UserAchievement;
|
} as UserAchievement;
|
||||||
})
|
})
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
|
|
|
@ -50,6 +50,7 @@ import "./user/unblock-user";
|
||||||
import "./user/get-user-friends";
|
import "./user/get-user-friends";
|
||||||
import "./user/get-user-stats";
|
import "./user/get-user-stats";
|
||||||
import "./user/report-user";
|
import "./user/report-user";
|
||||||
|
import "./user/get-compared-unlocked-achievements";
|
||||||
import "./profile/get-friend-requests";
|
import "./profile/get-friend-requests";
|
||||||
import "./profile/get-me";
|
import "./profile/get-me";
|
||||||
import "./profile/undo-friendship";
|
import "./profile/undo-friendship";
|
||||||
|
|
44
src/main/events/user/get-compared-unlocked-achievements.ts
Normal file
44
src/main/events/user/get-compared-unlocked-achievements.ts
Normal 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
|
||||||
|
);
|
|
@ -9,7 +9,7 @@ import {
|
||||||
getAlternativeObjectIds,
|
getAlternativeObjectIds,
|
||||||
} from "./find-achivement-files";
|
} from "./find-achivement-files";
|
||||||
import type { AchievementFile } from "@types";
|
import type { AchievementFile } from "@types";
|
||||||
import { achievementsLogger, logger } from "../logger";
|
import { achievementsLogger } from "../logger";
|
||||||
import { Cracker } from "@shared";
|
import { Cracker } from "@shared";
|
||||||
|
|
||||||
const fileStats: Map<string, number> = new Map();
|
const fileStats: Map<string, number> = new Map();
|
||||||
|
@ -55,8 +55,6 @@ const processAchievementFileDiff = async (
|
||||||
) => {
|
) => {
|
||||||
const unlockedAchievements = parseAchievementFile(file.filePath, file.type);
|
const unlockedAchievements = parseAchievementFile(file.filePath, file.type);
|
||||||
|
|
||||||
logger.log("Achievements from file", file.filePath, unlockedAchievements);
|
|
||||||
|
|
||||||
if (unlockedAchievements.length) {
|
if (unlockedAchievements.length) {
|
||||||
return mergeAchievements(
|
return mergeAchievements(
|
||||||
game.objectID,
|
game.objectID,
|
||||||
|
@ -80,7 +78,7 @@ const compareFltFolder = async (game: Game, file: AchievementFile) => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.log("Detected change in FLT folder", file.filePath);
|
achievementsLogger.log("Detected change in FLT folder", file.filePath);
|
||||||
await processAchievementFileDiff(game, file);
|
await processAchievementFileDiff(game, file);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
achievementsLogger.error(err);
|
achievementsLogger.error(err);
|
||||||
|
@ -101,6 +99,13 @@ const compareFile = async (game: Game, file: AchievementFile) => {
|
||||||
|
|
||||||
if (!previousStat) {
|
if (!previousStat) {
|
||||||
if (currentStat.mtimeMs) {
|
if (currentStat.mtimeMs) {
|
||||||
|
achievementsLogger.log(
|
||||||
|
"First change in file",
|
||||||
|
file.filePath,
|
||||||
|
previousStat,
|
||||||
|
currentStat.mtimeMs
|
||||||
|
);
|
||||||
|
|
||||||
await processAchievementFileDiff(game, file);
|
await processAchievementFileDiff(game, file);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -110,7 +115,7 @@ const compareFile = async (game: Game, file: AchievementFile) => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.log(
|
achievementsLogger.log(
|
||||||
"Detected change in file",
|
"Detected change in file",
|
||||||
file.filePath,
|
file.filePath,
|
||||||
previousStat,
|
previousStat,
|
||||||
|
|
|
@ -42,7 +42,9 @@ export const getGameAchievementData = async (
|
||||||
where: { objectId, shop },
|
where: { objectId, shop },
|
||||||
})
|
})
|
||||||
.then((gameAchievements) => {
|
.then((gameAchievements) => {
|
||||||
return JSON.parse(gameAchievements?.achievements || "[]");
|
return JSON.parse(
|
||||||
|
gameAchievements?.achievements || "[]"
|
||||||
|
) as AchievementData[];
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
@ -64,7 +64,13 @@ export const mergeAchievements = async (
|
||||||
localGameAchievement?.unlockedAchievements || "[]"
|
localGameAchievement?.unlockedAchievements || "[]"
|
||||||
).filter((achievement) => achievement.name) as UnlockedAchievement[];
|
).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) => {
|
.filter((achievement) => {
|
||||||
return !unlockedAchievements.some((localAchievement) => {
|
return !unlockedAchievements.some((localAchievement) => {
|
||||||
return (
|
return (
|
||||||
|
@ -114,7 +120,7 @@ export const mergeAchievements = async (
|
||||||
|
|
||||||
const mergedLocalAchievements = unlockedAchievements.concat(newAchievements);
|
const mergedLocalAchievements = unlockedAchievements.concat(newAchievements);
|
||||||
|
|
||||||
if (game?.remoteId) {
|
if (game.remoteId) {
|
||||||
return HydraApi.put(
|
return HydraApi.put(
|
||||||
"/profile/games/achievements",
|
"/profile/games/achievements",
|
||||||
{
|
{
|
||||||
|
|
|
@ -242,15 +242,23 @@ const processRld = (unlockedAchievements: any): UnlockedAchievement[] => {
|
||||||
const unlockedAchievement = unlockedAchievements[achievement];
|
const unlockedAchievement = unlockedAchievements[achievement];
|
||||||
|
|
||||||
if (unlockedAchievement?.State) {
|
if (unlockedAchievement?.State) {
|
||||||
newUnlockedAchievements.push({
|
const unlocked = new DataView(
|
||||||
name: achievement,
|
new Uint8Array(
|
||||||
unlockTime:
|
Buffer.from(unlockedAchievement.State.toString(), "hex")
|
||||||
new DataView(
|
).buffer
|
||||||
new Uint8Array(
|
).getUint32(0, true);
|
||||||
Buffer.from(unlockedAchievement.Time.toString(), "hex")
|
|
||||||
).buffer
|
if (unlocked === 1) {
|
||||||
).getUint32(0, true) * 1000,
|
newUnlockedAchievements.push({
|
||||||
});
|
name: achievement,
|
||||||
|
unlockTime:
|
||||||
|
new DataView(
|
||||||
|
new Uint8Array(
|
||||||
|
Buffer.from(unlockedAchievement.Time.toString(), "hex")
|
||||||
|
).buffer
|
||||||
|
).getUint32(0, true) * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -49,14 +49,14 @@ export const updateAllLocalUnlockedAchievements = async () => {
|
||||||
|
|
||||||
if (parsedAchievements.length) {
|
if (parsedAchievements.length) {
|
||||||
unlockedAchievements.push(...parsedAchievements);
|
unlockedAchievements.push(...parsedAchievements);
|
||||||
}
|
|
||||||
|
|
||||||
achievementsLogger.log(
|
achievementsLogger.log(
|
||||||
"Achievement file for",
|
"Achievement file for",
|
||||||
game.title,
|
game.title,
|
||||||
achievementFile.filePath,
|
achievementFile.filePath,
|
||||||
parsedAchievements
|
parsedAchievements
|
||||||
);
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
mergeAchievements(game.objectID, "steam", unlockedAchievements, false);
|
mergeAchievements(game.objectID, "steam", unlockedAchievements, false);
|
||||||
|
|
|
@ -80,6 +80,10 @@ export class WindowManager {
|
||||||
|
|
||||||
this.mainWindow.webContents.session.webRequest.onBeforeSendHeaders(
|
this.mainWindow.webContents.session.webRequest.onBeforeSendHeaders(
|
||||||
(details, callback) => {
|
(details, callback) => {
|
||||||
|
if (details.webContentsId !== this.mainWindow?.webContents.id) {
|
||||||
|
return callback(details);
|
||||||
|
}
|
||||||
|
|
||||||
const userAgent = new UserAgent();
|
const userAgent = new UserAgent();
|
||||||
|
|
||||||
callback({
|
callback({
|
||||||
|
|
|
@ -259,6 +259,17 @@ contextBridge.exposeInMainWorld("electron", {
|
||||||
getUserStats: (userId: string) => ipcRenderer.invoke("getUserStats", userId),
|
getUserStats: (userId: string) => ipcRenderer.invoke("getUserStats", userId),
|
||||||
reportUser: (userId: string, reason: string, description: string) =>
|
reportUser: (userId: string, reason: string, description: string) =>
|
||||||
ipcRenderer.invoke("reportUser", userId, reason, description),
|
ipcRenderer.invoke("reportUser", userId, reason, description),
|
||||||
|
getComparedUnlockedAchievements: (
|
||||||
|
objectId: string,
|
||||||
|
shop: GameShop,
|
||||||
|
userId: string
|
||||||
|
) =>
|
||||||
|
ipcRenderer.invoke(
|
||||||
|
"getComparedUnlockedAchievements",
|
||||||
|
objectId,
|
||||||
|
shop,
|
||||||
|
userId
|
||||||
|
),
|
||||||
|
|
||||||
/* Auth */
|
/* Auth */
|
||||||
signOut: () => ipcRenderer.invoke("signOut"),
|
signOut: () => ipcRenderer.invoke("signOut"),
|
||||||
|
|
6
src/renderer/src/declaration.d.ts
vendored
6
src/renderer/src/declaration.d.ts
vendored
|
@ -29,6 +29,7 @@ import type {
|
||||||
GameArtifact,
|
GameArtifact,
|
||||||
LudusaviBackup,
|
LudusaviBackup,
|
||||||
UserAchievement,
|
UserAchievement,
|
||||||
|
ComparedAchievements,
|
||||||
} from "@types";
|
} from "@types";
|
||||||
import type { AxiosProgressEvent } from "axios";
|
import type { AxiosProgressEvent } from "axios";
|
||||||
import type { DiskSpace } from "check-disk-space";
|
import type { DiskSpace } from "check-disk-space";
|
||||||
|
@ -202,6 +203,11 @@ declare global {
|
||||||
reason: string,
|
reason: string,
|
||||||
description: string
|
description: string
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
getComparedUnlockedAchievements: (
|
||||||
|
objectId: string,
|
||||||
|
shop: GameShop,
|
||||||
|
userId: string
|
||||||
|
) => Promise<ComparedAchievements>;
|
||||||
|
|
||||||
/* Profile */
|
/* Profile */
|
||||||
getMe: () => Promise<UserDetails | null>;
|
getMe: () => Promise<UserDetails | null>;
|
||||||
|
|
|
@ -36,15 +36,13 @@ export const buildGameDetailsPath = (
|
||||||
|
|
||||||
export const buildGameAchievementPath = (
|
export const buildGameAchievementPath = (
|
||||||
game: { shop: GameShop; objectId: string; title: string },
|
game: { shop: GameShop; objectId: string; title: string },
|
||||||
user?: { userId: string; displayName: string; profileImageUrl: string | null }
|
user?: { userId: string }
|
||||||
) => {
|
) => {
|
||||||
const searchParams = new URLSearchParams({
|
const searchParams = new URLSearchParams({
|
||||||
title: game.title,
|
title: game.title,
|
||||||
shop: game.shop,
|
shop: game.shop,
|
||||||
objectId: game.objectId,
|
objectId: game.objectId,
|
||||||
userId: user?.userId || "",
|
userId: user?.userId || "",
|
||||||
displayName: user?.displayName || "",
|
|
||||||
profileImageUrl: user?.profileImageUrl || "",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return `/achievements/?${searchParams.toString()}`;
|
return `/achievements/?${searchParams.toString()}`;
|
||||||
|
|
|
@ -72,7 +72,7 @@ export function useDate() {
|
||||||
const locale = getDateLocale();
|
const locale = getDateLocale();
|
||||||
return format(
|
return format(
|
||||||
date,
|
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"
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { useCallback } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { useAppDispatch, useAppSelector } from "./redux";
|
import { useAppDispatch, useAppSelector } from "./redux";
|
||||||
import {
|
import {
|
||||||
setProfileBackground,
|
setProfileBackground,
|
||||||
|
@ -129,7 +129,16 @@ export function useUserDetails() {
|
||||||
|
|
||||||
const unblockUser = (userId: string) => window.electron.unblockUser(userId);
|
const unblockUser = (userId: string) => window.electron.unblockUser(userId);
|
||||||
|
|
||||||
const hasActiveSubscription = userDetails?.subscription?.status === "active";
|
const hasActiveSubscription = useMemo(() => {
|
||||||
|
if (!userDetails?.subscription) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
userDetails.subscription.expiresAt == null ||
|
||||||
|
new Date(userDetails.subscription.expiresAt) > new Date()
|
||||||
|
);
|
||||||
|
}, [userDetails]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
userDetails,
|
userDetails,
|
||||||
|
|
|
@ -1,48 +1,57 @@
|
||||||
import { setHeaderTitle } from "@renderer/features";
|
import { setHeaderTitle } from "@renderer/features";
|
||||||
import { useAppDispatch, useDate, useUserDetails } from "@renderer/hooks";
|
import { useAppDispatch, useDate, useUserDetails } from "@renderer/hooks";
|
||||||
import { steamUrlBuilder } from "@shared";
|
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 { useTranslation } from "react-i18next";
|
||||||
import * as styles from "./achievements.css";
|
import * as styles from "./achievements.css";
|
||||||
import { formatDownloadProgress } from "@renderer/helpers";
|
import {
|
||||||
import { PersonIcon, TrophyIcon } from "@primer/octicons-react";
|
buildGameDetailsPath,
|
||||||
|
formatDownloadProgress,
|
||||||
|
} from "@renderer/helpers";
|
||||||
|
import { LockIcon, PersonIcon, TrophyIcon } from "@primer/octicons-react";
|
||||||
import { SPACING_UNIT, vars } from "@renderer/theme.css";
|
import { SPACING_UNIT, vars } from "@renderer/theme.css";
|
||||||
import { gameDetailsContext } from "@renderer/context";
|
import { gameDetailsContext } from "@renderer/context";
|
||||||
import { UserAchievement } from "@types";
|
import { ComparedAchievements, UserAchievement } from "@types";
|
||||||
import { average } from "color.js";
|
import { average } from "color.js";
|
||||||
import Color from "color";
|
import Color from "color";
|
||||||
|
import { Link } from "@renderer/components";
|
||||||
const HERO_ANIMATION_THRESHOLD = 25;
|
import { ComparedAchievementList } from "./compared-achievement-list";
|
||||||
|
|
||||||
interface UserInfo {
|
interface UserInfo {
|
||||||
userId: string;
|
userId: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
achievements: UserAchievement[];
|
|
||||||
profileImageUrl: string | null;
|
profileImageUrl: string | null;
|
||||||
|
totalAchievementCount: number;
|
||||||
|
unlockedAchievementCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AchievementsContentProps {
|
interface AchievementsContentProps {
|
||||||
otherUser: UserInfo | null;
|
otherUser: UserInfo | null;
|
||||||
|
comparedAchievements: ComparedAchievements | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AchievementListProps {
|
interface AchievementListProps {
|
||||||
achievements: UserAchievement[];
|
achievements: UserAchievement[];
|
||||||
otherUserAchievements?: UserAchievement[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AchievementPanelProps {
|
interface AchievementSummaryProps {
|
||||||
user: UserInfo;
|
user: UserInfo;
|
||||||
otherUser: UserInfo | null;
|
isComparison?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function AchievementPanel({ user, otherUser }: AchievementPanelProps) {
|
function AchievementSummary({ user, isComparison }: AchievementSummaryProps) {
|
||||||
const { t } = useTranslation("achievement");
|
const { t } = useTranslation("achievement");
|
||||||
|
const { userDetails, hasActiveSubscription } = useUserDetails();
|
||||||
|
|
||||||
const getProfileImage = (imageUrl: string | null | undefined) => {
|
const getProfileImage = (user: UserInfo) => {
|
||||||
return (
|
return (
|
||||||
<div className={styles.profileAvatar}>
|
<div className={styles.profileAvatar}>
|
||||||
{imageUrl ? (
|
{user.profileImageUrl ? (
|
||||||
<img className={styles.profileAvatar} src={imageUrl} alt={"teste"} />
|
<img
|
||||||
|
className={styles.profileAvatar}
|
||||||
|
src={user.profileImageUrl}
|
||||||
|
alt={user.displayName}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<PersonIcon size={24} />
|
<PersonIcon size={24} />
|
||||||
)}
|
)}
|
||||||
|
@ -50,331 +59,160 @@ function AchievementPanel({ user, otherUser }: AchievementPanelProps) {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const userTotalAchievementCount = user.achievements.length;
|
if (
|
||||||
const userUnlockedAchievementCount = user.achievements.filter(
|
isComparison &&
|
||||||
(achievement) => achievement.unlocked
|
userDetails?.id == user.userId &&
|
||||||
).length;
|
!hasActiveSubscription
|
||||||
|
) {
|
||||||
if (!otherUser) {
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "row",
|
|
||||||
width: "100%",
|
|
||||||
padding: `0 ${SPACING_UNIT * 2}px`,
|
|
||||||
gap: `${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
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
gap: `${SPACING_UNIT * 2}px`,
|
||||||
width: "100%",
|
alignItems: "center",
|
||||||
|
height: "62px",
|
||||||
|
position: "relative",
|
||||||
|
filter: "blur(4px)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<h1 style={{ fontSize: "1.2em", marginBottom: "8px" }}>
|
{getProfileImage(user)}
|
||||||
{t("your_achievements")}
|
<h1 style={{ marginBottom: "8px" }}>{user.displayName}</h1>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const otherUserUnlockedAchievementCount = otherUser.achievements.filter(
|
|
||||||
(achievement) => achievement.unlocked
|
|
||||||
).length;
|
|
||||||
const otherUserTotalAchievementCount = otherUser.achievements.length;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: `${SPACING_UNIT * 2}px`,
|
||||||
|
alignItems: "center",
|
||||||
|
padding: `${SPACING_UNIT}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{getProfileImage(user)}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "row",
|
flexDirection: "column",
|
||||||
width: "100%",
|
width: "100%",
|
||||||
padding: `0 ${SPACING_UNIT * 2}px`,
|
|
||||||
gap: `${SPACING_UNIT * 2}px`,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{getProfileImage(otherUser.profileImageUrl)}
|
<h1 style={{ marginBottom: "8px" }}>{user.displayName}</h1>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
justifyContent: "space-between",
|
||||||
width: "100%",
|
marginBottom: 8,
|
||||||
|
color: vars.color.muted,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<h1 style={{ fontSize: "1.2em", marginBottom: "8px" }}>
|
|
||||||
{t("user_achievements", {
|
|
||||||
displayName: otherUser.displayName,
|
|
||||||
})}
|
|
||||||
</h1>
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
justifyContent: "space-between",
|
alignItems: "center",
|
||||||
marginBottom: 8,
|
gap: 8,
|
||||||
width: "100%",
|
|
||||||
color: vars.color.muted,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<TrophyIcon size={13} />
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TrophyIcon size={13} />
|
|
||||||
<span>
|
|
||||||
{otherUserUnlockedAchievementCount} /{" "}
|
|
||||||
{otherUserTotalAchievementCount}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span>
|
<span>
|
||||||
{formatDownloadProgress(
|
{user.unlockedAchievementCount} / {user.totalAchievementCount}
|
||||||
otherUserUnlockedAchievementCount /
|
|
||||||
otherUserTotalAchievementCount
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
<span>
|
||||||
{formatDownloadProgress(
|
{formatDownloadProgress(
|
||||||
userUnlockedAchievementCount / userTotalAchievementCount
|
user.unlockedAchievementCount / user.totalAchievementCount
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
|
||||||
<progress
|
|
||||||
max={1}
|
|
||||||
value={userUnlockedAchievementCount / userTotalAchievementCount}
|
|
||||||
className={styles.achievementsProgressBar}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<progress
|
||||||
|
max={1}
|
||||||
|
value={user.unlockedAchievementCount / user.totalAchievementCount}
|
||||||
|
className={styles.achievementsProgressBar}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AchievementList({
|
function AchievementList({ achievements }: AchievementListProps) {
|
||||||
achievements,
|
|
||||||
otherUserAchievements,
|
|
||||||
}: AchievementListProps) {
|
|
||||||
const { t } = useTranslation("achievement");
|
const { t } = useTranslation("achievement");
|
||||||
const { formatDateTime } = useDate();
|
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 (
|
return (
|
||||||
<ul className={styles.list}>
|
<ul className={styles.list}>
|
||||||
{otherUserAchievements.map((otherUserAchievement, index) => (
|
{achievements.map((achievement, index) => (
|
||||||
<li
|
<li key={index} className={styles.listItem} style={{ display: "flex" }}>
|
||||||
key={index}
|
<img
|
||||||
className={styles.listItem}
|
className={styles.listItemImage({
|
||||||
style={{ display: "grid", gridTemplateColumns: "1fr auto 1fr" }}
|
unlocked: achievement.unlocked,
|
||||||
>
|
})}
|
||||||
<div
|
src={achievement.icon}
|
||||||
style={{
|
alt={achievement.displayName}
|
||||||
display: "flex",
|
loading="lazy"
|
||||||
flexDirection: "row",
|
/>
|
||||||
alignItems: "center",
|
<div style={{ flex: 1 }}>
|
||||||
gap: `${SPACING_UNIT}px`,
|
<h4>{achievement.displayName}</h4>
|
||||||
}}
|
<p>{achievement.description}</p>
|
||||||
>
|
|
||||||
<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>
|
</div>
|
||||||
|
{achievement.unlockTime && (
|
||||||
|
<div style={{ whiteSpace: "nowrap" }}>
|
||||||
|
<small>{t("unlocked_at")}</small>
|
||||||
|
<p>{formatDateTime(achievement.unlockTime)}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AchievementsContent({ otherUser }: AchievementsContentProps) {
|
export function AchievementsContent({
|
||||||
|
otherUser,
|
||||||
|
comparedAchievements,
|
||||||
|
}: AchievementsContentProps) {
|
||||||
const heroRef = useRef<HTMLDivElement | null>(null);
|
const heroRef = useRef<HTMLDivElement | null>(null);
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [isHeaderStuck, setIsHeaderStuck] = useState(false);
|
const [isHeaderStuck, setIsHeaderStuck] = useState(false);
|
||||||
const [backdropOpactiy, setBackdropOpacity] = useState(1);
|
|
||||||
|
|
||||||
const { gameTitle, objectId, shop, achievements, gameColor, setGameColor } =
|
const { gameTitle, objectId, shop, achievements, gameColor, setGameColor } =
|
||||||
useContext(gameDetailsContext);
|
useContext(gameDetailsContext);
|
||||||
|
|
||||||
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 dispatch = useAppDispatch();
|
||||||
|
|
||||||
const { userDetails } = useUserDetails();
|
const { userDetails, hasActiveSubscription } = useUserDetails();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (gameTitle) {
|
if (gameTitle) {
|
||||||
|
@ -399,11 +237,6 @@ export function AchievementsContent({ otherUser }: AchievementsContentProps) {
|
||||||
const heroHeight = heroRef.current?.clientHeight ?? styles.HERO_HEIGHT;
|
const heroHeight = heroRef.current?.clientHeight ?? styles.HERO_HEIGHT;
|
||||||
|
|
||||||
const scrollY = (event.target as HTMLDivElement).scrollTop;
|
const scrollY = (event.target as HTMLDivElement).scrollTop;
|
||||||
const opacity = Math.max(
|
|
||||||
0,
|
|
||||||
1 - scrollY / (heroHeight - HERO_ANIMATION_THRESHOLD)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (scrollY >= heroHeight && !isHeaderStuck) {
|
if (scrollY >= heroHeight && !isHeaderStuck) {
|
||||||
setIsHeaderStuck(true);
|
setIsHeaderStuck(true);
|
||||||
}
|
}
|
||||||
|
@ -411,8 +244,25 @@ export function AchievementsContent({ otherUser }: AchievementsContentProps) {
|
||||||
if (scrollY <= heroHeight && isHeaderStuck) {
|
if (scrollY <= heroHeight && isHeaderStuck) {
|
||||||
setIsHeaderStuck(false);
|
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;
|
if (!objectId || !shop || !gameTitle || !userDetails) return null;
|
||||||
|
@ -421,8 +271,9 @@ export function AchievementsContent({ otherUser }: AchievementsContentProps) {
|
||||||
<div className={styles.wrapper}>
|
<div className={styles.wrapper}>
|
||||||
<img
|
<img
|
||||||
src={steamUrlBuilder.libraryHero(objectId)}
|
src={steamUrlBuilder.libraryHero(objectId)}
|
||||||
|
style={{ display: "none" }}
|
||||||
alt={gameTitle}
|
alt={gameTitle}
|
||||||
className={styles.hero}
|
className={styles.heroImage}
|
||||||
onLoad={handleHeroLoad}
|
onLoad={handleHeroLoad}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
@ -431,39 +282,86 @@ export function AchievementsContent({ otherUser }: AchievementsContentProps) {
|
||||||
onScroll={onScroll}
|
onScroll={onScroll}
|
||||||
className={styles.container}
|
className={styles.container}
|
||||||
>
|
>
|
||||||
<div ref={heroRef} className={styles.header}>
|
<div
|
||||||
<div
|
style={{
|
||||||
style={{
|
display: "flex",
|
||||||
backgroundColor: gameColor,
|
flexDirection: "column",
|
||||||
flex: 1,
|
background: `linear-gradient(0deg, ${vars.color.darkBackground} 0%, ${gameColor} 100%)`,
|
||||||
opacity: Math.min(1, 1 - backdropOpactiy),
|
}}
|
||||||
}}
|
>
|
||||||
/>
|
<div ref={heroRef} className={styles.hero}>
|
||||||
|
<div className={styles.heroContent}>
|
||||||
|
<Link
|
||||||
|
to={buildGameDetailsPath({ shop, objectId, title: gameTitle })}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={steamUrlBuilder.logo(objectId)}
|
||||||
|
className={styles.gameLogo}
|
||||||
|
alt={gameTitle}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={styles.heroLogoBackdrop}
|
style={{
|
||||||
style={{ opacity: backdropOpactiy }}
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
width: "100%",
|
||||||
|
gap: `${SPACING_UNIT}px`,
|
||||||
|
padding: `${SPACING_UNIT}px`,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className={styles.heroContent}>
|
<AchievementSummary
|
||||||
<img
|
user={{
|
||||||
src={steamUrlBuilder.logo(objectId)}
|
...userDetails,
|
||||||
className={styles.gameLogo}
|
userId: userDetails.id,
|
||||||
alt={gameTitle}
|
totalAchievementCount: comparedAchievements
|
||||||
/>
|
? comparedAchievements.ownerUser.totalAchievementCount
|
||||||
</div>
|
: achievements!.length,
|
||||||
|
unlockedAchievementCount: comparedAchievements
|
||||||
|
? comparedAchievements.ownerUser.unlockedAchievementCount
|
||||||
|
: achievements!.filter((achievement) => achievement.unlocked)
|
||||||
|
.length,
|
||||||
|
}}
|
||||||
|
isComparison={otherUser !== null}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{otherUser && <AchievementSummary user={otherUser} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.panel({ stuck: isHeaderStuck })}>
|
{otherUser && (
|
||||||
<AchievementPanel
|
<div className={styles.tableHeader({ stuck: isHeaderStuck })}>
|
||||||
user={{
|
<div
|
||||||
...userDetails,
|
style={{
|
||||||
userId: userDetails.id,
|
display: "grid",
|
||||||
achievements: achievements!,
|
gridTemplateColumns: hasActiveSubscription
|
||||||
}}
|
? "3fr 1fr 1fr"
|
||||||
otherUser={otherUser}
|
: "3fr 2fr",
|
||||||
/>
|
gap: `${SPACING_UNIT * 2}px`,
|
||||||
</div>
|
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
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
|
@ -472,10 +370,11 @@ export function AchievementsContent({ otherUser }: AchievementsContentProps) {
|
||||||
backgroundColor: vars.color.background,
|
backgroundColor: vars.color.background,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<AchievementList
|
{otherUser ? (
|
||||||
achievements={sortedAchievements}
|
<ComparedAchievementList achievements={comparedAchievements!} />
|
||||||
otherUserAchievements={otherUser?.achievements}
|
) : (
|
||||||
/>
|
<AchievementList achievements={achievements!} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -4,7 +4,7 @@ import * as styles from "./achievements.css";
|
||||||
export function AchievementsSkeleton() {
|
export function AchievementsSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={styles.hero}>
|
<div className={styles.heroImage}>
|
||||||
<Skeleton className={styles.heroImageSkeleton} />
|
<Skeleton className={styles.heroImageSkeleton} />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.heroPanelSkeleton}></div>
|
<div className={styles.heroPanelSkeleton}></div>
|
||||||
|
|
|
@ -2,7 +2,9 @@ import { SPACING_UNIT, vars } from "../../theme.css";
|
||||||
import { style } from "@vanilla-extract/css";
|
import { style } from "@vanilla-extract/css";
|
||||||
import { recipe } from "@vanilla-extract/recipes";
|
import { recipe } from "@vanilla-extract/recipes";
|
||||||
|
|
||||||
export const HERO_HEIGHT = 300;
|
export const HERO_HEIGHT = 150;
|
||||||
|
export const LOGO_HEIGHT = 100;
|
||||||
|
export const LOGO_MAX_WIDTH = 200;
|
||||||
|
|
||||||
export const wrapper = style({
|
export const wrapper = style({
|
||||||
display: "flex",
|
display: "flex",
|
||||||
|
@ -13,11 +15,11 @@ export const wrapper = style({
|
||||||
transition: "all ease 0.3s",
|
transition: "all ease 0.3s",
|
||||||
});
|
});
|
||||||
|
|
||||||
export const header = style({
|
export const hero = style({
|
||||||
display: "flex",
|
width: "100%",
|
||||||
height: `${HERO_HEIGHT}px`,
|
height: `${HERO_HEIGHT}px`,
|
||||||
minHeight: `${HERO_HEIGHT}px`,
|
minHeight: `${HERO_HEIGHT}px`,
|
||||||
gap: `${SPACING_UNIT}px`,
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
position: "relative",
|
position: "relative",
|
||||||
transition: "all ease 0.2s",
|
transition: "all ease 0.2s",
|
||||||
|
@ -29,27 +31,41 @@ export const header = style({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const hero = style({
|
export const heroImage = style({
|
||||||
position: "absolute",
|
|
||||||
inset: "0",
|
|
||||||
borderRadius: "4px",
|
|
||||||
objectFit: "cover",
|
|
||||||
cursor: "pointer",
|
|
||||||
width: "100%",
|
width: "100%",
|
||||||
|
height: `${HERO_HEIGHT}px`,
|
||||||
|
minHeight: `${HERO_HEIGHT}px`,
|
||||||
|
objectFit: "cover",
|
||||||
|
objectPosition: "top",
|
||||||
transition: "all ease 0.2s",
|
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({
|
export const heroContent = style({
|
||||||
padding: `${SPACING_UNIT * 2}px`,
|
padding: `${SPACING_UNIT * 2}px`,
|
||||||
height: "100%",
|
|
||||||
width: "100%",
|
width: "100%",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
alignItems: "flex-end",
|
alignItems: "center",
|
||||||
});
|
});
|
||||||
|
|
||||||
export const gameLogo = style({
|
export const gameLogo = style({
|
||||||
width: 300,
|
width: LOGO_MAX_WIDTH,
|
||||||
|
height: LOGO_HEIGHT,
|
||||||
|
objectFit: "contain",
|
||||||
|
transition: "all ease 0.2s",
|
||||||
|
":hover": {
|
||||||
|
transform: "scale(1.05)",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const container = style({
|
export const container = style({
|
||||||
|
@ -61,19 +77,13 @@ export const container = style({
|
||||||
zIndex: "1",
|
zIndex: "1",
|
||||||
});
|
});
|
||||||
|
|
||||||
export const panel = recipe({
|
export const tableHeader = recipe({
|
||||||
base: {
|
base: {
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: "100px",
|
|
||||||
minHeight: "100px",
|
|
||||||
padding: `${SPACING_UNIT * 2}px 0`,
|
|
||||||
backgroundColor: vars.color.darkBackground,
|
backgroundColor: vars.color.darkBackground,
|
||||||
display: "flex",
|
|
||||||
flexDirection: "row",
|
|
||||||
transition: "all ease 0.2s",
|
transition: "all ease 0.2s",
|
||||||
borderBottom: `solid 1px ${vars.color.border}`,
|
borderBottom: `solid 1px ${vars.color.border}`,
|
||||||
position: "sticky",
|
position: "sticky",
|
||||||
overflow: "hidden",
|
|
||||||
top: "0",
|
top: "0",
|
||||||
zIndex: "1",
|
zIndex: "1",
|
||||||
},
|
},
|
||||||
|
@ -145,7 +155,6 @@ export const achievementsProgressBar = style({
|
||||||
export const heroLogoBackdrop = style({
|
export const heroLogoBackdrop = style({
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
background: "linear-gradient(0deg, rgba(0, 0, 0, 0.3) 60%, transparent 100%)",
|
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
|
@ -180,8 +189,20 @@ export const listItemSkeleton = style({
|
||||||
});
|
});
|
||||||
|
|
||||||
export const profileAvatar = style({
|
export const profileAvatar = style({
|
||||||
height: "65px",
|
height: "54px",
|
||||||
width: "65px",
|
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",
|
borderRadius: "4px",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { setHeaderTitle } from "@renderer/features";
|
import { setHeaderTitle } from "@renderer/features";
|
||||||
import { useAppDispatch, useUserDetails } from "@renderer/hooks";
|
import { useAppDispatch, useUserDetails } from "@renderer/hooks";
|
||||||
import type { GameShop, UserAchievement } from "@types";
|
import type { ComparedAchievements, GameShop } from "@types";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useSearchParams } from "react-router-dom";
|
import { useSearchParams } from "react-router-dom";
|
||||||
import { vars } from "@renderer/theme.css";
|
import { vars } from "@renderer/theme.css";
|
||||||
import {
|
import {
|
||||||
|
@ -18,14 +18,11 @@ export default function Achievements() {
|
||||||
const shop = searchParams.get("shop");
|
const shop = searchParams.get("shop");
|
||||||
const title = searchParams.get("title");
|
const title = searchParams.get("title");
|
||||||
const userId = searchParams.get("userId");
|
const userId = searchParams.get("userId");
|
||||||
const displayName = searchParams.get("displayName");
|
|
||||||
const profileImageUrl = searchParams.get("profileImageUrl");
|
|
||||||
|
|
||||||
const { userDetails } = useUserDetails();
|
const { userDetails } = useUserDetails();
|
||||||
|
|
||||||
const [otherUserAchievements, setOtherUserAchievements] = useState<
|
const [comparedAchievements, setComparedAchievements] =
|
||||||
UserAchievement[] | null
|
useState<ComparedAchievements | null>(null);
|
||||||
>(null);
|
|
||||||
|
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
@ -36,31 +33,34 @@ export default function Achievements() {
|
||||||
}, [dispatch, title]);
|
}, [dispatch, title]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setOtherUserAchievements(null);
|
setComparedAchievements(null);
|
||||||
|
|
||||||
if (userDetails?.id == userId) {
|
if (userDetails?.id == userId) {
|
||||||
setOtherUserAchievements([]);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (objectId && shop && userId) {
|
if (objectId && shop && userId) {
|
||||||
window.electron
|
window.electron
|
||||||
.getGameAchievements(objectId, shop as GameShop, userId)
|
.getComparedUnlockedAchievements(objectId, shop as GameShop, userId)
|
||||||
.then((achievements) => {
|
.then(setComparedAchievements);
|
||||||
setOtherUserAchievements(achievements);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}, [objectId, shop, userId]);
|
}, [objectId, shop, userId]);
|
||||||
|
|
||||||
const otherUserId = userDetails?.id === userId ? null : userId;
|
const otherUserId = userDetails?.id === userId ? null : userId;
|
||||||
|
|
||||||
const otherUser = otherUserId
|
const otherUser = useMemo(() => {
|
||||||
? {
|
if (!otherUserId || !comparedAchievements) return null;
|
||||||
userId: otherUserId,
|
|
||||||
displayName: displayName || "",
|
return {
|
||||||
achievements: otherUserAchievements || [],
|
userId: otherUserId,
|
||||||
profileImageUrl: profileImageUrl || "",
|
displayName: comparedAchievements.otherUser.displayName,
|
||||||
}
|
profileImageUrl: comparedAchievements.otherUser.profileImageUrl,
|
||||||
: null;
|
totalAchievementCount:
|
||||||
|
comparedAchievements.otherUser.totalAchievementCount,
|
||||||
|
unlockedAchievementCount:
|
||||||
|
comparedAchievements.otherUser.unlockedAchievementCount,
|
||||||
|
};
|
||||||
|
}, [otherUserId, comparedAchievements]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GameDetailsContextProvider
|
<GameDetailsContextProvider
|
||||||
|
@ -70,17 +70,23 @@ export default function Achievements() {
|
||||||
>
|
>
|
||||||
<GameDetailsContextConsumer>
|
<GameDetailsContextConsumer>
|
||||||
{({ isLoading, achievements }) => {
|
{({ isLoading, achievements }) => {
|
||||||
|
const showSkeleton =
|
||||||
|
isLoading ||
|
||||||
|
achievements === null ||
|
||||||
|
(otherUserId && comparedAchievements === null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SkeletonTheme
|
<SkeletonTheme
|
||||||
baseColor={vars.color.background}
|
baseColor={vars.color.background}
|
||||||
highlightColor="#444"
|
highlightColor="#444"
|
||||||
>
|
>
|
||||||
{isLoading ||
|
{showSkeleton ? (
|
||||||
achievements === null ||
|
|
||||||
(otherUserId && otherUserAchievements === null) ? (
|
|
||||||
<AchievementsSkeleton />
|
<AchievementsSkeleton />
|
||||||
) : (
|
) : (
|
||||||
<AchievementsContent otherUser={otherUser} />
|
<AchievementsContent
|
||||||
|
otherUser={otherUser}
|
||||||
|
comparedAchievements={comparedAchievements!}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</SkeletonTheme>
|
</SkeletonTheme>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -24,6 +24,8 @@ const fakeAchievements: UserAchievement[] = [
|
||||||
hidden: false,
|
hidden: false,
|
||||||
description: "Chop down your first tree.",
|
description: "Chop down your first tree.",
|
||||||
icon: "https://cdn.akamai.steamstatic.com/steamcommunity/public/images/apps/105600/0fbb33098c9da39d1d4771d8209afface9c46e81.jpg",
|
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,
|
unlocked: true,
|
||||||
unlockTime: Date.now(),
|
unlockTime: Date.now(),
|
||||||
},
|
},
|
||||||
|
@ -32,6 +34,8 @@ const fakeAchievements: UserAchievement[] = [
|
||||||
name: "",
|
name: "",
|
||||||
hidden: false,
|
hidden: false,
|
||||||
icon: "https://cdn.akamai.steamstatic.com/steamcommunity/public/images/apps/105600/0a6ff6a36670c96ceb4d30cf6fd69d2fdf55f38e.jpg",
|
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,
|
unlocked: false,
|
||||||
unlockTime: null,
|
unlockTime: null,
|
||||||
},
|
},
|
||||||
|
@ -40,6 +44,8 @@ const fakeAchievements: UserAchievement[] = [
|
||||||
name: "",
|
name: "",
|
||||||
hidden: false,
|
hidden: false,
|
||||||
icon: "https://cdn.akamai.steamstatic.com/steamcommunity/public/images/apps/105600/2d10311274fe7c92ab25cc29afdca86b019ad472.jpg",
|
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,
|
unlocked: false,
|
||||||
unlockTime: null,
|
unlockTime: null,
|
||||||
},
|
},
|
||||||
|
|
|
@ -46,7 +46,7 @@ export function ProfileContent() {
|
||||||
}, [userProfile]);
|
}, [userProfile]);
|
||||||
|
|
||||||
const buildUserGameDetailsPath = (game: UserGame) => {
|
const buildUserGameDetailsPath = (game: UserGame) => {
|
||||||
if (!userProfile?.hasActiveSubscription) {
|
if (!userProfile?.hasActiveSubscription || game.achievementCount === 0) {
|
||||||
return buildGameDetailsPath({
|
return buildGameDetailsPath({
|
||||||
...game,
|
...game,
|
||||||
objectId: game.objectId,
|
objectId: game.objectId,
|
||||||
|
@ -56,8 +56,6 @@ export function ProfileContent() {
|
||||||
const userParams = userProfile
|
const userParams = userProfile
|
||||||
? {
|
? {
|
||||||
userId: userProfile.id,
|
userId: userProfile.id,
|
||||||
displayName: userProfile.displayName,
|
|
||||||
profileImageUrl: userProfile.profileImageUrl,
|
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
@ -174,55 +172,56 @@ export function ProfileContent() {
|
||||||
{formatPlayTime(game.playTimeInSeconds)}
|
{formatPlayTime(game.playTimeInSeconds)}
|
||||||
</small>
|
</small>
|
||||||
|
|
||||||
{userProfile.hasActiveSubscription && (
|
{userProfile.hasActiveSubscription &&
|
||||||
<div
|
game.achievementCount > 0 && (
|
||||||
style={{
|
|
||||||
color: "white",
|
|
||||||
width: "100%",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
color: "white",
|
||||||
|
width: "100%",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
justifyContent: "space-between",
|
flexDirection: "column",
|
||||||
marginBottom: 8,
|
|
||||||
color: vars.color.muted,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
justifyContent: "space-between",
|
||||||
gap: 8,
|
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>
|
<span>
|
||||||
{game.unlockedAchievementCount} /{" "}
|
{formatDownloadProgress(
|
||||||
{game.achievementCount}
|
game.unlockedAchievementCount /
|
||||||
|
game.achievementCount
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span>
|
<progress
|
||||||
{formatDownloadProgress(
|
max={1}
|
||||||
|
value={
|
||||||
game.unlockedAchievementCount /
|
game.unlockedAchievementCount /
|
||||||
game.achievementCount
|
game.achievementCount
|
||||||
)}
|
}
|
||||||
</span>
|
className={styles.achievementsProgressBar}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<progress
|
|
||||||
max={1}
|
|
||||||
value={
|
|
||||||
game.unlockedAchievementCount /
|
|
||||||
game.achievementCount
|
|
||||||
}
|
|
||||||
className={styles.achievementsProgressBar}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<img
|
<img
|
||||||
|
|
|
@ -45,6 +45,7 @@ export interface UserAchievement {
|
||||||
unlocked: boolean;
|
unlocked: boolean;
|
||||||
unlockTime: number | null;
|
unlockTime: number | null;
|
||||||
icon: string;
|
icon: string;
|
||||||
|
icongray: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RemoteUnlockedAchievement {
|
export interface RemoteUnlockedAchievement {
|
||||||
|
@ -341,6 +342,33 @@ export interface GameArtifact {
|
||||||
downloadCount: number;
|
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 "./steam.types";
|
||||||
export * from "./real-debrid.types";
|
export * from "./real-debrid.types";
|
||||||
export * from "./ludusavi.types";
|
export * from "./ludusavi.types";
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue