feat: initial profile refactor

This commit is contained in:
Chubby Granny Chaser 2024-09-12 00:53:16 +01:00
parent 6273ca1376
commit ada7b452a0
No known key found for this signature in database
48 changed files with 10733 additions and 922 deletions

9712
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,9 +1,8 @@
import { getSteamAppAsset } from "@main/helpers";
import type { CatalogueEntry, GameShop } from "@types";
import { registerEvent } from "../register-event";
import { RepacksManager, requestSteam250 } from "@main/services";
import { formatName } from "@shared";
import { formatName, steamUrlBuilder } from "@shared";
const resultSize = 12;
@ -24,7 +23,7 @@ const getCatalogue = async (_event: Electron.IpcMainInvokeEvent) => {
objectID,
title,
shop: "steam" as GameShop,
cover: getSteamAppAsset("library", objectID),
cover: steamUrlBuilder.library(objectID),
};
results.push({ ...catalogueEntry, repacks });

View File

@ -3,9 +3,9 @@ import flexSearch from "flexsearch";
import type { GameShop, CatalogueEntry, SteamGame } from "@types";
import { getSteamAppAsset } from "@main/helpers";
import { steamGamesWorker } from "@main/workers";
import { RepacksManager } from "@main/services";
import { steamUrlBuilder } from "@shared";
export interface SearchGamesArgs {
query?: string;
@ -19,7 +19,7 @@ export const convertSteamGameToCatalogueEntry = (
objectID: String(game.id),
title: game.name,
shop: "steam" as GameShop,
cover: getSteamAppAsset("library", String(game.id)),
cover: steamUrlBuilder.library(String(game.id)),
repacks: [],
});

View File

@ -3,10 +3,11 @@ import { gameRepository } from "@main/repository";
import { registerEvent } from "../register-event";
import type { GameShop } from "@types";
import { getFileBase64, getSteamAppAsset } from "@main/helpers";
import { getFileBase64 } from "@main/helpers";
import { steamGamesWorker } from "@main/workers";
import { createGame } from "@main/services/library-sync";
import { steamUrlBuilder } from "@shared";
const addGameToLibrary = async (
_event: Electron.IpcMainInvokeEvent,
@ -32,7 +33,7 @@ const addGameToLibrary = async (
});
const iconUrl = steamGame?.clientIcon
? getSteamAppAsset("icon", objectID, steamGame.clientIcon)
? steamUrlBuilder.icon(objectID, steamGame.clientIcon)
: null;
await gameRepository

View File

@ -1,18 +1,18 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import { HydraApi, logger } from "@main/services";
import axios from "axios";
import fs from "node:fs";
import path from "node:path";
import { fileTypeFromFile } from "file-type";
import { UpdateProfileProps, UserProfile } from "@types";
import type { UpdateProfileRequest, UserProfile } from "@types";
const patchUserProfile = async (updateProfile: UpdateProfileProps) => {
const patchUserProfile = async (updateProfile: UpdateProfileRequest) => {
return HydraApi.patch("/profile", updateProfile);
};
const updateProfile = async (
_event: Electron.IpcMainInvokeEvent,
updateProfile: UpdateProfileProps
updateProfile: UpdateProfileRequest
): Promise<UserProfile> => {
if (!updateProfile.profileImageUrl) {
return patchUserProfile(updateProfile);
@ -40,7 +40,11 @@ const updateProfile = async (
});
return profileImageUrl as string;
})
.catch(() => undefined);
.catch((err) => {
logger.error("Error uploading profile image", err);
return undefined;
});
return patchUserProfile({ ...updateProfile, profileImageUrl });
};

View File

@ -7,12 +7,13 @@ import {
import { registerEvent } from "../register-event";
import type { StartGameDownloadPayload } from "@types";
import { getFileBase64, getSteamAppAsset } from "@main/helpers";
import { getFileBase64 } from "@main/helpers";
import { DownloadManager } from "@main/services";
import { Not } from "typeorm";
import { steamGamesWorker } from "@main/workers";
import { createGame } from "@main/services/library-sync";
import { steamUrlBuilder } from "@shared";
const startGameDownload = async (
_event: Electron.IpcMainInvokeEvent,
@ -65,7 +66,7 @@ const startGameDownload = async (
});
const iconUrl = steamGame?.clientIcon
? getSteamAppAsset("icon", objectID, steamGame.clientIcon)
? steamUrlBuilder.icon(objectID, steamGame.clientIcon)
: null;
await gameRepository

View File

@ -1,10 +1,9 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import { steamGamesWorker } from "@main/workers";
import { GameRunning, UserGame, UserProfile } from "@types";
import { convertSteamGameToCatalogueEntry } from "../helpers/search-games";
import { getSteamAppAsset } from "@main/helpers";
import type { UserProfile } from "@types";
import { getUserFriends } from "./get-user-friends";
import { steamUrlBuilder } from "@shared";
const getUser = async (
_event: Electron.IpcMainInvokeEvent,
@ -12,65 +11,47 @@ const getUser = async (
): Promise<UserProfile | null> => {
try {
const [profile, friends] = await Promise.all([
HydraApi.get(`/users/${userId}`),
HydraApi.get<UserProfile | null>(`/users/${userId}`),
getUserFriends(userId, 12, 0).catch(() => {
return { totalFriends: 0, friends: [] };
}),
]);
if (!profile) return null;
const recentGames = await Promise.all(
profile.recentGames.map(async (game) => {
return getSteamUserGame(game);
const steamGame = await steamGamesWorker.run(Number(game.objectId), {
name: "getById",
});
return {
...game,
title: steamGame.name,
iconUrl: steamUrlBuilder.icon(game.objectId, steamGame.clientIcon),
};
})
);
const libraryGames = await Promise.all(
profile.libraryGames.map(async (game) => {
return getSteamUserGame(game);
})
);
// const libraryGames = await Promise.all(
// profile.libraryGames.map(async (game) => {
// return getSteamUserGame(game);
// })
// );
const currentGame = await getGameRunning(profile.currentGame);
// const currentGame = await getGameRunning(profile.currentGame);
return {
...profile,
libraryGames,
// libraryGames,
recentGames,
friends: friends.friends,
totalFriends: friends.totalFriends,
currentGame,
// currentGame,
};
} catch (err) {
return null;
}
};
const getGameRunning = async (currentGame): Promise<GameRunning | null> => {
if (!currentGame) {
return null;
}
const gameRunning = await getSteamUserGame(currentGame);
return {
...gameRunning,
sessionDurationInMillis: currentGame.sessionDurationInSeconds * 1000,
};
};
const getSteamUserGame = async (game): Promise<UserGame> => {
const steamGame = await steamGamesWorker.run(Number(game.objectId), {
name: "getById",
});
const iconUrl = steamGame?.clientIcon
? getSteamAppAsset("icon", game.objectId, steamGame.clientIcon)
: null;
return {
...game,
...convertSteamGameToCatalogueEntry(steamGame),
iconUrl,
};
};
registerEvent("getUser", getUser);

View File

@ -2,23 +2,6 @@ import axios from "axios";
import { JSDOM } from "jsdom";
import UserAgent from "user-agents";
export const getSteamAppAsset = (
category: "library" | "hero" | "logo" | "icon",
objectID: string,
clientIcon?: string
) => {
if (category === "library")
return `https://steamcdn-a.akamaihd.net/steam/apps/${objectID}/header.jpg`;
if (category === "hero")
return `https://steamcdn-a.akamaihd.net/steam/apps/${objectID}/library_hero.jpg`;
if (category === "logo")
return `https://cdn.cloudflare.steamstatic.com/steam/apps/${objectID}/logo.png`;
return `https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/${objectID}/${clientIcon}.ico`;
};
export const getFileBuffer = async (url: string) =>
fetch(url, { method: "GET" }).then((response) =>
response.arrayBuffer().then((buffer) => Buffer.from(buffer))
@ -34,15 +17,6 @@ export const getFileBase64 = async (url: string) =>
})
);
export const steamUrlBuilder = {
library: (objectID: string) =>
`https://steamcdn-a.akamaihd.net/steam/apps/${objectID}/header.jpg`,
libraryHero: (objectID: string) =>
`https://steamcdn-a.akamaihd.net/steam/apps/${objectID}/library_hero.jpg`,
logo: (objectID: string) =>
`https://cdn.cloudflare.steamstatic.com/steam/apps/${objectID}/logo.png`,
};
export const sleep = (ms: number) =>
new Promise((resolve) => setTimeout(resolve, ms));

View File

@ -77,54 +77,54 @@ export class HydraApi {
baseURL: import.meta.env.MAIN_VITE_API_URL,
});
this.instance.interceptors.request.use(
(request) => {
logger.log(" ---- REQUEST -----");
logger.log(request.method, request.url, request.params, request.data);
return request;
},
(error) => {
logger.error("request error", error);
return Promise.reject(error);
}
);
// this.instance.interceptors.request.use(
// (request) => {
// logger.log(" ---- REQUEST -----");
// logger.log(request.method, request.url, request.params, request.data);
// return request;
// },
// (error) => {
// logger.error("request error", error);
// return Promise.reject(error);
// }
// );
this.instance.interceptors.response.use(
(response) => {
logger.log(" ---- RESPONSE -----");
logger.log(
response.status,
response.config.method,
response.config.url,
response.data
);
return response;
},
(error) => {
logger.error(" ---- RESPONSE ERROR -----");
// this.instance.interceptors.response.use(
// (response) => {
// logger.log(" ---- RESPONSE -----");
// logger.log(
// response.status,
// response.config.method,
// response.config.url,
// response.data
// );
// return response;
// },
// (error) => {
// logger.error(" ---- RESPONSE ERROR -----");
const { config } = error;
// const { config } = error;
logger.error(
config.method,
config.baseURL,
config.url,
config.headers,
config.data
);
// logger.error(
// config.method,
// config.baseURL,
// config.url,
// config.headers,
// config.data
// );
if (error.response) {
logger.error("Response", error.response.status, error.response.data);
} else if (error.request) {
logger.error("Request", error.request);
} else {
logger.error("Error", error.message);
}
// if (error.response) {
// logger.error("Response", error.response.status, error.response.data);
// } else if (error.request) {
// logger.error("Request", error.request);
// } else {
// logger.error("Error", error.message);
// }
logger.error(" ----- END RESPONSE ERROR -------");
return Promise.reject(error);
}
);
// logger.error(" ----- END RESPONSE ERROR -------");
// return Promise.reject(error);
// }
// );
const userAuth = await userAuthRepository.findOne({
where: { id: 1 },

View File

@ -1,7 +1,7 @@
import { gameRepository } from "@main/repository";
import { HydraApi } from "../hydra-api";
import { steamGamesWorker } from "@main/workers";
import { getSteamAppAsset } from "@main/helpers";
import { steamUrlBuilder } from "@shared";
export const mergeWithRemoteGames = async () => {
return HydraApi.get("/profile/games")
@ -44,7 +44,7 @@ export const mergeWithRemoteGames = async () => {
if (steamGame) {
const iconUrl = steamGame?.clientIcon
? getSteamAppAsset("icon", game.objectId, steamGame.clientIcon)
? steamUrlBuilder.icon(game.objectId, steamGame.clientIcon)
: null;
gameRepository.insert({

View File

@ -10,7 +10,7 @@ import type {
StartGameDownloadPayload,
GameRunning,
FriendRequestAction,
UpdateProfileProps,
UpdateProfileRequest,
} from "@types";
contextBridge.exposeInMainWorld("electron", {
@ -138,7 +138,7 @@ contextBridge.exposeInMainWorld("electron", {
getMe: () => ipcRenderer.invoke("getMe"),
undoFriendship: (userId: string) =>
ipcRenderer.invoke("undoFriendship", userId),
updateProfile: (updateProfile: UpdateProfileProps) =>
updateProfile: (updateProfile: UpdateProfileRequest) =>
ipcRenderer.invoke("updateProfile", updateProfile),
getFriendRequests: () => ipcRenderer.invoke("getFriendRequests"),
updateFriendRequest: (userId: string, action: FriendRequestAction) =>

View File

@ -39,7 +39,7 @@ export function Header({ onSearch, onClear, search }: HeaderProps) {
const title = useMemo(() => {
if (location.pathname.startsWith("/game")) return headerTitle;
if (location.pathname.startsWith("/user")) return headerTitle;
if (location.pathname.startsWith("/profile")) return headerTitle;
if (location.pathname.startsWith("/search")) return t("search_results");
return t(pathTitle[location.pathname]);

View File

@ -2,12 +2,9 @@ import { useNavigate } from "react-router-dom";
import * as styles from "./hero.css";
import { useEffect, useState } from "react";
import { ShopDetails } from "@types";
import {
buildGameDetailsPath,
getSteamLanguage,
steamUrlBuilder,
} from "@renderer/helpers";
import { buildGameDetailsPath, getSteamLanguage } from "@renderer/helpers";
import { useTranslation } from "react-i18next";
import { steamUrlBuilder } from "@shared";
const FEATURED_GAME_TITLE = "ELDEN RING";
const FEATURED_GAME_ID = "1245620";

View File

@ -1,21 +1,13 @@
import { createVar, style } from "@vanilla-extract/css";
import { style } from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "../../theme.css";
export const profileContainerBackground = createVar();
export const profileContainer = style({
background: profileContainerBackground,
position: "relative",
display: "flex",
alignItems: "center",
gap: `${SPACING_UNIT}px`,
cursor: "pointer",
":hover": {
backgroundColor: "rgba(255, 255, 255, 0.15)",
},
borderBottom: `solid 1px ${vars.color.border}`,
boxShadow: "0px 0px 15px 0px rgb(0 0 0 / 70%)",
padding: `${SPACING_UNIT * 2}px ${SPACING_UNIT * 2}px`,
padding: `${SPACING_UNIT}px ${SPACING_UNIT * 2}px`,
});
export const profileButton = style({
@ -25,13 +17,17 @@ export const profileButton = style({
color: vars.color.muted,
width: "100%",
overflow: "hidden",
borderRadius: "4px",
padding: `${SPACING_UNIT}px ${SPACING_UNIT}px`,
":hover": {
backgroundColor: "rgba(255, 255, 255, 0.15)",
},
});
export const profileButtonContent = style({
display: "flex",
alignItems: "center",
gap: `${SPACING_UNIT + SPACING_UNIT / 2}px`,
height: "40px",
width: "100%",
});
@ -77,14 +73,31 @@ export const profileButtonTitle = style({
whiteSpace: "nowrap",
});
export const friendRequestButton = style({
color: vars.color.success,
export const friendsButton = style({
color: vars.color.muted,
cursor: "pointer",
borderRadius: "50%",
overflow: "hidden",
width: "40px",
minWidth: "40px",
minHeight: "40px",
height: "40px",
backgroundColor: vars.color.background,
position: "relative",
transition: "all ease 0.3s",
":hover": {
color: vars.color.muted,
backgroundColor: "#DADBE1",
},
});
export const friendsButtonLabel = style({
backgroundColor: vars.color.success,
display: "flex",
justifyContent: "center",
alignItems: "center",
width: "20px",
height: "20px",
borderRadius: "50%",
position: "absolute",
top: "-5px",
right: "-5px",
});

View File

@ -1,11 +1,9 @@
import { useNavigate } from "react-router-dom";
import { PersonAddIcon, PersonIcon } from "@primer/octicons-react";
import { PeopleIcon, PersonIcon } from "@primer/octicons-react";
import * as styles from "./sidebar-profile.css";
import { assignInlineVars } from "@vanilla-extract/dynamic";
import { useAppSelector, useUserDetails } from "@renderer/hooks";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { profileContainerBackground } from "./sidebar-profile.css";
import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal";
import { FriendRequest } from "@types";
@ -14,8 +12,7 @@ export function SidebarProfile() {
const { t } = useTranslation("sidebar");
const { userDetails, profileBackground, friendRequests, showFriendsModal } =
useUserDetails();
const { userDetails, friendRequests, showFriendsModal } = useUserDetails();
const [receivedRequests, setReceivedRequests] = useState<FriendRequest[]>([]);
@ -33,24 +30,11 @@ export function SidebarProfile() {
return;
}
navigate(`/user/${userDetails!.id}`);
navigate(`/profile/${userDetails!.id}`);
};
const profileButtonBackground = useMemo(() => {
if (profileBackground) return profileBackground;
return undefined;
}, [profileBackground]);
const showPendingRequests =
userDetails && receivedRequests.length > 0 && !gameRunning;
return (
<div
className={styles.profileContainer}
style={assignInlineVars({
[profileContainerBackground]: profileButtonBackground,
})}
>
<div className={styles.profileContainer}>
<button
type="button"
className={styles.profileButton}
@ -91,18 +75,18 @@ export function SidebarProfile() {
)}
</div>
</button>
{showPendingRequests && (
<button
type="button"
className={styles.friendRequestButton}
onClick={() =>
showFriendsModal(UserFriendModalTab.AddFriend, userDetails.id)
}
>
<PersonAddIcon size={24} />
{receivedRequests.length}
</button>
)}
<button
type="button"
className={styles.friendsButton}
onClick={() =>
showFriendsModal(UserFriendModalTab.AddFriend, userDetails.id)
}
>
<small className={styles.friendsButtonLabel}>10</small>
<PeopleIcon size={16} />
</button>
</div>
);
}

View File

@ -24,22 +24,13 @@ export const sidebar = recipe({
},
});
export const content = recipe({
base: {
display: "flex",
flexDirection: "column",
padding: `${SPACING_UNIT * 2}px`,
gap: `${SPACING_UNIT * 2}px`,
width: "100%",
overflow: "auto",
},
variants: {
macos: {
true: {
paddingTop: `${SPACING_UNIT * 6}px`,
},
},
},
export const content = style({
display: "flex",
flexDirection: "column",
padding: `${SPACING_UNIT * 2}px`,
gap: `${SPACING_UNIT * 2}px`,
width: "100%",
overflow: "auto",
});
export const handle = style({

View File

@ -15,6 +15,7 @@ import { buildGameDetailsPath } from "@renderer/helpers";
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
import { SidebarProfile } from "./sidebar-profile";
import { sortBy } from "lodash-es";
import { ChevronDownIcon } from "@primer/octicons-react";
const SIDEBAR_MIN_WIDTH = 200;
const SIDEBAR_INITIAL_WIDTH = 250;
@ -157,17 +158,12 @@ export function Sidebar() {
width: sidebarWidth,
minWidth: sidebarWidth,
maxWidth: sidebarWidth,
paddingTop: 8 * 6,
}}
>
<SidebarProfile />
<div
className={styles.content({
macos: window.electron.platform === "darwin",
})}
>
{window.electron.platform === "darwin" && <h2>Hydra</h2>}
<div className={styles.content}>
<section className={styles.section}>
<ul className={styles.menu}>
{routes.map(({ nameKey, path, render }) => (
@ -184,6 +180,8 @@ export function Sidebar() {
>
{render(isDownloading)}
<span>{t(nameKey)}</span>
<ChevronDownIcon />
</button>
</li>
))}

View File

@ -1,2 +1,3 @@
export * from "./game-details/game-details.context";
export * from "./settings/settings.context";
export * from "./user-profile/user-profile.context";

View File

@ -0,0 +1,90 @@
import { darkenColor } from "@renderer/helpers";
import { useAppSelector, useToast } from "@renderer/hooks";
import type { UserProfile } from "@types";
import { average } from "color.js";
import { createContext, useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
export interface UserProfileContext {
userProfile: UserProfile | null;
heroBackground: string;
/* Indicates if the current user is viewing their own profile */
isMe: boolean;
}
export const DEFAULT_USER_PROFILE_BACKGROUND = "#151515B3";
export const userProfileContext = createContext<UserProfileContext>({
userProfile: null,
heroBackground: DEFAULT_USER_PROFILE_BACKGROUND,
isMe: false,
});
const { Provider } = userProfileContext;
export const { Consumer: UserProfileContextConsumer } = userProfileContext;
export interface UserProfileContextProviderProps {
children: React.ReactNode;
userId: string;
}
export function UserProfileContextProvider({
children,
userId,
}: UserProfileContextProviderProps) {
const { userDetails } = useAppSelector((state) => state.userDetails);
const [userProfile, setUserProfile] = useState<UserProfile | null>(null);
const [heroBackground, setHeroBackground] = useState(
DEFAULT_USER_PROFILE_BACKGROUND
);
const getHeroBackgroundFromImageUrl = async (imageUrl: string) => {
const output = await average(imageUrl, {
amount: 1,
format: "hex",
});
return `linear-gradient(135deg, ${darkenColor(output as string, 0.6)}, ${darkenColor(output as string, 0.8, 0.7)})`;
};
const { t } = useTranslation("user_profile");
const { showErrorToast } = useToast();
const navigate = useNavigate();
const getUserProfile = useCallback(async () => {
return window.electron.getUser(userId).then((userProfile) => {
if (userProfile) {
setUserProfile(userProfile);
if (userProfile.profileImageUrl) {
getHeroBackgroundFromImageUrl(userProfile.profileImageUrl).then(
(color) => setHeroBackground(color)
);
}
} else {
showErrorToast(t("user_not_found"));
navigate(-1);
}
});
}, [navigate, showErrorToast, userId, t]);
useEffect(() => {
getUserProfile();
}, [getUserProfile]);
return (
<Provider
value={{
userProfile,
heroBackground,
isMe: userDetails?.id === userProfile?.id,
}}
>
{children}
</Provider>
);
}

View File

@ -18,6 +18,7 @@ import type {
FriendRequestAction,
UserFriends,
UserBlocks,
UpdateProfileRequest,
} from "@types";
import type { DiskSpace } from "check-disk-space";
@ -141,7 +142,9 @@ declare global {
/* Profile */
getMe: () => Promise<UserProfile | null>;
undoFriendship: (userId: string) => Promise<void>;
updateProfile: (updateProfile: UpdateProfileProps) => Promise<UserProfile>;
updateProfile: (
updateProfile: UpdateProfileRequest
) => Promise<UserProfile>;
getFriendRequests: () => Promise<FriendRequest[]>;
updateFriendRequest: (
userId: string,

View File

@ -1,16 +1,6 @@
import type { GameShop } from "@types";
import Color from "color";
import { average } from "color.js";
export const steamUrlBuilder = {
library: (objectID: string) =>
`https://steamcdn-a.akamaihd.net/steam/apps/${objectID}/header.jpg`,
libraryHero: (objectID: string) =>
`https://steamcdn-a.akamaihd.net/steam/apps/${objectID}/library_hero.jpg`,
logo: (objectID: string) =>
`https://cdn.cloudflare.steamstatic.com/steam/apps/${objectID}/logo.png`,
};
export const formatDownloadProgress = (progress?: number) => {
if (!progress) return "0%";
@ -46,14 +36,3 @@ export const buildGameDetailsPath = (
export const darkenColor = (color: string, amount: number, alpha: number = 1) =>
new Color(color).darken(amount).alpha(alpha).toString();
export const profileBackgroundFromProfileImage = async (
profileImageUrl: string
) => {
const output = await average(profileImageUrl, {
amount: 1,
format: "hex",
});
return `linear-gradient(135deg, ${darkenColor(output as string, 0.6)}, ${darkenColor(output as string, 0.8, 0.7)})`;
};

View File

View File

@ -7,8 +7,12 @@ import {
setFriendsModalVisible,
setFriendsModalHidden,
} from "@renderer/features";
import { profileBackgroundFromProfileImage } from "@renderer/helpers";
import { FriendRequestAction, UpdateProfileProps, UserDetails } from "@types";
// import { profileBackgroundFromProfileImage } from "@renderer/helpers";
import type {
FriendRequestAction,
UpdateProfileRequest,
UserDetails,
} from "@types";
import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal";
import { logger } from "@renderer/logger";
@ -42,12 +46,12 @@ export function useUserDetails() {
dispatch(setUserDetails(userDetails));
if (userDetails.profileImageUrl) {
const profileBackground = await profileBackgroundFromProfileImage(
userDetails.profileImageUrl
).catch((err) => {
logger.error("profileBackgroundFromProfileImage", err);
return `#151515B3`;
});
// const profileBackground = await profileBackgroundFromProfileImage(
// userDetails.profileImageUrl
// ).catch((err) => {
// logger.error("profileBackgroundFromProfileImage", err);
// return `#151515B3`;
// });
dispatch(setProfileBackground(profileBackground));
window.localStorage.setItem(
@ -78,8 +82,9 @@ export function useUserDetails() {
}, [clearUserDetails]);
const patchUser = useCallback(
async (props: UpdateProfileProps) => {
const response = await window.electron.updateProfile(props);
async (values: UpdateProfileRequest) => {
console.log("values", values);
const response = await window.electron.updateProfile(values);
return updateUserDetails(response);
},
[updateUserDetails]

View File

@ -22,12 +22,12 @@ import {
SearchResults,
Settings,
Catalogue,
Profile,
} from "@renderer/pages";
import { store } from "./store";
import * as resources from "@locales";
import { User } from "./pages/user/user";
Sentry.init({});
@ -57,7 +57,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
<Route path="/game/:shop/:objectID" Component={GameDetails} />
<Route path="/search" Component={SearchResults} />
<Route path="/settings" Component={Settings} />
<Route path="/user/:userId" Component={User} />
<Route path="/profile/:userId" Component={Profile} />
</Route>
</Routes>
</HashRouter>

View File

@ -6,10 +6,9 @@ import { Badge, Button } from "@renderer/components";
import {
buildGameDetailsPath,
formatDownloadProgress,
steamUrlBuilder,
} from "@renderer/helpers";
import { Downloader, formatBytes } from "@shared";
import { Downloader, formatBytes, steamUrlBuilder } from "@shared";
import { DOWNLOADER_NAME } from "@renderer/constants";
import { useAppSelector, useDownload } from "@renderer/hooks";

View File

@ -2,8 +2,6 @@ import { useContext, useEffect, useRef, useState } from "react";
import { average } from "color.js";
import Color from "color";
import { steamUrlBuilder } from "@renderer/helpers";
import { HeroPanel } from "./hero";
import { DescriptionHeader } from "./description-header/description-header";
import { GallerySlider } from "./gallery-slider/gallery-slider";
@ -12,6 +10,7 @@ import { Sidebar } from "./sidebar/sidebar";
import * as styles from "./game-details.css";
import { useTranslation } from "react-i18next";
import { gameDetailsContext } from "@renderer/context";
import { steamUrlBuilder } from "@shared";
const HERO_ANIMATION_THRESHOLD = 25;

View File

@ -4,3 +4,4 @@ export * from "./downloads/downloads";
export * from "./home/search-results";
export * from "./settings/settings";
export * from "./catalogue/catalogue";
export * from "./profile/profile";

View File

@ -0,0 +1,73 @@
import { vars, SPACING_UNIT } from "../../theme.css";
import { globalStyle, style } from "@vanilla-extract/css";
export const gameCover = style({
transition: "all ease 0.2s",
boxShadow: "0 8px 10px -2px rgba(0, 0, 0, 0.5)",
":before": {
content: "",
top: "0",
left: "0",
width: "100%",
height: "172%",
position: "absolute",
background:
"linear-gradient(35deg, rgba(0, 0, 0, 0.1) 0%, rgba(0, 0, 0, 0.07) 51.5%, rgba(255, 255, 255, 0.15) 54%, rgba(255, 255, 255, 0.15) 100%);",
transition: "all ease 0.3s",
transform: "translateY(-36%)",
opacity: "0.5",
},
});
export const game = style({
transition: "all ease 0.2s",
":hover": {
transform: "scale(1.05)",
},
});
globalStyle(`${gameCover}:hover::before`, {
opacity: "1",
transform: "translateY(-20%)",
});
export const box = style({
backgroundColor: vars.color.background,
borderRadius: "4px",
border: `solid 1px ${vars.color.border}`,
padding: `${SPACING_UNIT * 2}px`,
});
export const sectionHeader = style({
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginBottom: `${SPACING_UNIT * 2}px`,
});
export const list = style({
listStyle: "none",
margin: "0",
padding: "0",
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT * 2}px`,
});
export const friend = style({
display: "flex",
gap: `${SPACING_UNIT}px`,
alignItems: "center",
});
export const friendAvatar = style({
width: "50px",
height: "50px",
borderRadius: "4px",
});
export const friendName = style({
color: vars.color.muted,
fontWeight: "bold",
fontSize: vars.size.body,
});

View File

@ -0,0 +1,172 @@
import { userProfileContext } from "@renderer/context";
import { useContext, useEffect, useMemo } from "react";
import { ProfileHero } from "./profile-hero/profile-hero";
import { useAppDispatch } from "@renderer/hooks";
import { setHeaderTitle } from "@renderer/features";
import { steamUrlBuilder } from "@shared";
import { SPACING_UNIT } from "@renderer/theme.css";
import * as styles from "./profile-content.css";
import { ClockIcon, PeopleIcon } from "@primer/octicons-react";
export function ProfileContent() {
const { userProfile } = useContext(userProfileContext);
const dispatch = useAppDispatch();
useEffect(() => {
if (userProfile) {
dispatch(setHeaderTitle(userProfile.displayName));
}
}, [userProfile, dispatch]);
const truncatedGamesList = useMemo(() => {
return userProfile?.libraryGames.slice(0, 12);
}, [userProfile?.libraryGames]);
return (
<div>
<ProfileHero />
<section
style={{
display: "flex",
gap: `${SPACING_UNIT * 3}px`,
padding: `${SPACING_UNIT * 3}px`,
}}
>
<div style={{}}>
<div className={styles.sectionHeader}>
<h2>Library</h2>
<h3>{userProfile?.libraryGames.length}</h3>
</div>
<ul
style={{
listStyle: "none",
margin: 0,
padding: 0,
display: "grid",
gridTemplateColumns: "repeat(6, 1fr)",
gap: `${SPACING_UNIT * 2}px`,
}}
>
{truncatedGamesList.map((game) => (
<li
key={game.objectId}
style={{
borderRadius: 4,
overflow: "hidden",
position: "relative",
}}
className={styles.game}
>
<button
type="button"
style={{ cursor: "pointer" }}
className={styles.gameCover}
>
<img
src={steamUrlBuilder.cover(game.objectId)}
alt={game.title}
style={{ width: "100%" }}
/>
</button>
</li>
))}
</ul>
</div>
<div
style={{
minWidth: 350,
display: "flex",
gap: SPACING_UNIT * 2,
flexDirection: "column",
}}
>
<div>
<div className={styles.sectionHeader}>
<h2>Played recently</h2>
</div>
<div className={styles.box}>
<ul className={styles.list}>
{userProfile?.recentGames.map((game) => (
<li>
<button
type="button"
style={{
cursor: "pointer",
display: "flex",
gap: `${SPACING_UNIT}px`,
}}
>
<img
src={game.iconUrl}
alt={game.title}
style={{
width: "30px",
height: "30px",
borderRadius: "4px",
}}
/>
<div style={{ display: "flex", flexDirection: "column" }}>
<span style={{ fontWeight: "bold" }}>{game.title}</span>
<div
style={{
display: "flex",
alignItems: "center",
gap: `${SPACING_UNIT / 2}px`,
}}
>
<ClockIcon />
<span>{game.playTimeInSeconds}</span>
</div>
</div>
</button>
</li>
))}
</ul>
</div>
</div>
<div>
<div className={styles.sectionHeader}>
<h2>Friends</h2>
<span>{userProfile?.totalFriends}</span>
</div>
<div className={styles.box}>
<ul className={styles.list}>
{userProfile?.friends.map((friend) => (
<li>
<button
type="button"
style={{ cursor: "pointer" }}
className={styles.friend}
>
<img
src={friend.profileImageUrl}
alt={friend.displayName}
style={{ width: "100%" }}
className={styles.friendAvatar}
/>
<span className={styles.friendName}>
{friend.displayName}
</span>
</button>
</li>
))}
</ul>
</div>
</div>
</div>
</section>
</div>
);
}

View File

@ -0,0 +1,46 @@
import { SPACING_UNIT, vars } from "../../../theme.css";
import { style } from "@vanilla-extract/css";
export const profileContentBox = style({
display: "flex",
flexDirection: "column",
});
export const profileAvatarContainer = style({
width: "96px",
minWidth: "96px",
height: "96px",
borderRadius: "50%",
display: "flex",
justifyContent: "center",
alignItems: "center",
backgroundColor: vars.color.background,
overflow: "hidden",
border: `solid 1px ${vars.color.border}`,
boxShadow: "0px 0px 5px 0px rgba(0, 0, 0, 0.7)",
zIndex: 1,
});
export const profileAvatar = style({
height: "100%",
width: "100%",
objectFit: "cover",
overflow: "hidden",
});
export const profileInformation = style({
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT}px`,
alignItems: "flex-start",
color: "#c0c1c7",
zIndex: 1,
overflow: "hidden",
});
export const profileDisplayName = style({
fontWeight: "bold",
overflow: "hidden",
textOverflow: "ellipsis",
width: "100%",
});

View File

@ -0,0 +1,191 @@
import { SPACING_UNIT } from "@renderer/theme.css";
import * as styles from "./profile-hero.css";
import { useContext, useMemo } from "react";
import { userProfileContext } from "@renderer/context";
import {
CheckCircleFillIcon,
PersonIcon,
XCircleFillIcon,
} from "@primer/octicons-react";
import { buildGameDetailsPath } from "@renderer/helpers";
import { Button, Link } from "@renderer/components";
import { useTranslation } from "react-i18next";
import { useDate } from "@renderer/hooks";
export function ProfileHero() {
const { userProfile, heroBackground, isMe } = useContext(userProfileContext);
const { t } = useTranslation("user_profile");
const { formatDistance } = useDate();
if (!userProfile) return null;
const { currentGame } = userProfile;
console.log(userProfile);
const profileActions = useMemo(() => {
if (isMe) {
return (
<>
<Button theme="outline">{t("settings")}</Button>
<Button theme="danger">{t("sign_out")}</Button>
</>
);
}
// if (userProfile.relation == null) {
// return (
// <>
// <Button
// theme="outline"
// onClick={() => handleFriendAction(userProfile.id, "SEND")}
// >
// {t("add_friend")}
// </Button>
// <Button theme="danger" onClick={() => setShowUserBlockModal(true)}>
// {t("block_user")}
// </Button>
// </>
// );
// }
// if (userProfile.relation.status === "ACCEPTED") {
// return (
// <>
// <Button
// theme="outline"
// // className={styles.cancelRequestButton}
// // onClick={() => setShowUndoFriendshipModal(true)}
// >
// <XCircleFillIcon size={28} /> {t("undo_friendship")}
// </Button>
// </>
// );
// }
// if (userProfile.relation.BId === userProfile.id) {
// return (
// <Button
// theme="outline"
// // className={styles.cancelRequestButton}
// // onClick={() =>
// // handleFriendAction(userProfile.relation!.BId, "CANCEL")
// // }
// >
// <XCircleFillIcon size={28} /> {t("cancel_request")}
// </Button>
// );
// }
return (
<>
<Button
theme="outline"
// onClick={() =>
// handleFriendAction(userProfile.relation!.AId, "ACCEPTED")
// }
>
<CheckCircleFillIcon size={28} /> {t("accept_request")}
</Button>
<Button
theme="outline"
// onClick={() =>
// handleFriendAction(userProfile.relation!.AId, "REFUSED")
// }
>
<XCircleFillIcon size={28} /> {t("ignore_request")}
</Button>
</>
);
}, []);
return (
<>
<section
className={styles.profileContentBox}
style={{ background: heroBackground }}
>
<div
style={{
display: "flex",
padding: `${SPACING_UNIT * 4}px ${SPACING_UNIT * 3}px`,
alignItems: "center",
gap: `${SPACING_UNIT * 2}px`,
}}
>
<div className={styles.profileAvatarContainer}>
{userProfile.profileImageUrl ? (
<img
className={styles.profileAvatar}
alt={userProfile.displayName}
src={userProfile.profileImageUrl}
/>
) : (
<PersonIcon size={72} />
)}
</div>
<div className={styles.profileInformation}>
<h2 className={styles.profileDisplayName}>
{userProfile.displayName}
</h2>
{currentGame && (
<div
style={{
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT / 2}px`,
}}
>
<div
style={{
display: "flex",
flexDirection: "row",
gap: `${SPACING_UNIT}px`,
alignItems: "center",
}}
>
<Link to={buildGameDetailsPath(currentGame)}>
{currentGame.title}
</Link>
</div>
<small>
{t("playing_for", {
amount: formatDistance(
currentGame.sessionDurationInSeconds,
new Date()
),
})}
</small>
</div>
)}
</div>
</div>
<div
style={{
width: "100%",
height: "72px",
minHeight: "72px",
padding: `${SPACING_UNIT * 2}px ${SPACING_UNIT * 3}px`,
display: "flex",
gap: `${SPACING_UNIT}px`,
justifyContent: "space-between",
backdropFilter: `blur(10px)`,
borderTop: `solid 1px rgba(255, 255, 255, 0.1)`,
boxShadow: "0px 0px 15px 0px rgba(0, 0, 0, 0.5)",
}}
>
<div></div>
<div style={{ display: "flex", gap: `${SPACING_UNIT}px` }}>
{profileActions}
</div>
</div>
</section>
</>
);
}

View File

@ -1,11 +1,12 @@
import Skeleton from "react-loading-skeleton";
import cn from "classnames";
import * as styles from "./user.css";
import * as styles from "./profile.css";
import { SPACING_UNIT } from "@renderer/theme.css";
import { useTranslation } from "react-i18next";
export const UserSkeleton = () => {
export function ProfileSkeleton() {
const { t } = useTranslation("user_profile");
return (
<>
<Skeleton className={styles.profileHeaderSkeleton} />
@ -38,4 +39,4 @@ export const UserSkeleton = () => {
</div>
</>
);
};
}

View File

@ -2,7 +2,6 @@ import { SPACING_UNIT, vars } from "../../theme.css";
import { style } from "@vanilla-extract/css";
export const wrapper = style({
padding: "24px",
width: "100%",
display: "flex",
flexDirection: "column",

View File

@ -0,0 +1,29 @@
import { useParams } from "react-router-dom";
import { ProfileSkeleton } from "./profile-skeleton";
import { ProfileContent } from "./profile-content";
import { SkeletonTheme } from "react-loading-skeleton";
import { vars } from "@renderer/theme.css";
import * as styles from "./profile.css";
import {
UserProfileContextConsumer,
UserProfileContextProvider,
} from "@renderer/context";
export function Profile() {
const { userId } = useParams();
return (
<UserProfileContextProvider userId={userId!}>
<SkeletonTheme baseColor={vars.color.background} highlightColor="#444">
<div className={styles.wrapper}>
<UserProfileContextConsumer>
{({ userProfile }) =>
userProfile ? <ProfileContent /> : <ProfileSkeleton />
}
</UserProfileContextConsumer>
</div>
</SkeletonTheme>
</UserProfileContextProvider>
);
}

View File

@ -1,5 +1,5 @@
import { Button, Modal } from "@renderer/components";
import * as styles from "./user.css";
import * as styles from "./profile.css";
import { useTranslation } from "react-i18next";
export interface UserBlockModalProps {

View File

@ -1,5 +1,5 @@
import { Button, Modal } from "@renderer/components";
import * as styles from "./user.css";
import * as styles from "./profile.css";
import { useTranslation } from "react-i18next";
export interface UserConfirmUndoFriendshipModalProps {

View File

@ -4,7 +4,7 @@ import { useToast, useUserDetails } from "@renderer/hooks";
import { UserProfile } from "@types";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import * as styles from "../user.css";
import * as styles from "../profile.css";
import { SPACING_UNIT } from "@renderer/theme.css";
export interface UserEditProfileProps {
@ -21,7 +21,7 @@ export const UserEditProfile = ({
const [form, setForm] = useState({
displayName: userProfile.displayName,
profileVisibility: userProfile.profileVisibility,
imageProfileUrl: null as string | null,
profileImageUrl: null as string | null,
});
const [isSaving, setIsSaving] = useState(false);
@ -55,7 +55,7 @@ export const UserEditProfile = ({
if (filePaths && filePaths.length > 0) {
const path = filePaths[0];
setForm({ ...form, imageProfileUrl: path });
setForm({ ...form, profileImageUrl: path });
}
};
@ -86,7 +86,7 @@ export const UserEditProfile = ({
};
const avatarUrl = useMemo(() => {
if (form.imageProfileUrl) return `local:${form.imageProfileUrl}`;
if (form.profileImageUrl) return `local:${form.profileImageUrl}`;
if (userProfile.profileImageUrl) return userProfile.profileImageUrl;
return null;
}, [form, userProfile]);

View File

@ -1,5 +1,5 @@
import { Button, Modal } from "@renderer/components";
import * as styles from "./user.css";
import * as styles from "./profile.css";
import { useTranslation } from "react-i18next";
export interface UserSignOutModalProps {

View File

@ -1,27 +1,106 @@
import { style } from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "../../theme.css";
import { recipe } from "@vanilla-extract/recipes";
export const container = style({
padding: "24px",
width: "100%",
display: "flex",
gap: `${SPACING_UNIT * 2}px`,
alignItems: "flex-start",
});
export const content = style({
backgroundColor: vars.color.background,
width: "100%",
height: "100%",
padding: `${SPACING_UNIT * 3}px`,
border: `solid 1px ${vars.color.border}`,
boxShadow: "0px 0px 15px 0px #000000",
borderRadius: "8px",
boxShadow: "0px 0px 15px 0px rgba(0, 0, 0, 0.5)",
borderRadius: "4px",
gap: `${SPACING_UNIT * 2}px`,
display: "flex",
flexDirection: "column",
flex: "1",
});
export const sidebar = style({
width: "200px",
display: "flex",
border: `solid 1px ${vars.color.border}`,
borderRadius: "4px",
backgroundColor: vars.color.background,
minHeight: "500px",
flexDirection: "column",
padding: `${SPACING_UNIT * 2}px ${SPACING_UNIT}px`,
gap: `${SPACING_UNIT * 2}px`,
boxShadow: "0px 0px 15px 0px rgba(0, 0, 0, 0.5)",
});
export const menuGroup = style({
gap: `${SPACING_UNIT}px`,
display: "flex",
flexDirection: "column",
});
export const settingsCategories = style({
export const menu = style({
listStyle: "none",
margin: "0",
padding: "0",
gap: `${SPACING_UNIT / 2}px`,
display: "flex",
gap: `${SPACING_UNIT}px`,
flexDirection: "column",
overflow: "hidden",
});
export const menuItem = recipe({
base: {
transition: "all ease 0.1s",
cursor: "pointer",
textWrap: "nowrap",
display: "flex",
color: vars.color.muted,
borderRadius: "4px",
":hover": {
backgroundColor: "rgba(255, 255, 255, 0.15)",
},
},
variants: {
active: {
true: {
backgroundColor: "rgba(255, 255, 255, 0.1)",
},
},
muted: {
true: {
opacity: vars.opacity.disabled,
":hover": {
opacity: "1",
},
},
},
},
});
export const menuItemButton = style({
color: "inherit",
display: "flex",
alignItems: "center",
gap: `${SPACING_UNIT}px`,
cursor: "pointer",
overflow: "hidden",
width: "100%",
padding: `9px ${SPACING_UNIT}px`,
});
export const menuItemButtonLabel = style({
textOverflow: "ellipsis",
overflow: "hidden",
});
export const categoryTitle = style({
color: "#ff",
fontWeight: "bold",
fontSize: "18px",
paddingBottom: `${SPACING_UNIT}px`,
});

View File

@ -1,5 +1,3 @@
import { Button } from "@renderer/components";
import * as styles from "./settings.css";
import { useTranslation } from "react-i18next";
import { SettingsRealDebrid } from "./settings-real-debrid";
@ -15,12 +13,10 @@ import {
export function Settings() {
const { t } = useTranslation("settings");
const categories = [
t("general"),
t("behavior"),
t("download_sources"),
"Real-Debrid",
];
const categories = {
[t("account")]: [t("my_profile"), t("friends")],
Hydra: [t("general"), t("behavior"), t("download_sources"), "Real-Debrid"],
};
return (
<SettingsContextProvider>
@ -44,21 +40,34 @@ export function Settings() {
return (
<section className={styles.container}>
<div className={styles.content}>
<section className={styles.settingsCategories}>
{categories.map((category, index) => (
<Button
key={category}
theme={
currentCategoryIndex === index ? "primary" : "outline"
}
onClick={() => setCurrentCategoryIndex(index)}
>
{category}
</Button>
))}
</section>
<aside className={styles.sidebar}>
{Object.entries(categories).map(([category, items]) => (
<div key={category} className={styles.menuGroup}>
<span className={styles.categoryTitle}>{category}</span>
<ul className={styles.menu}>
{items.map((item, index) => (
<li
key={`item-${index}`}
className={styles.menuItem({
active: currentCategoryIndex === index,
})}
>
<button
type="button"
className={styles.menuItemButton}
onClick={() => setCurrentCategoryIndex(index)}
>
{item}
</button>
</li>
))}
</ul>
</div>
))}
</aside>
<div className={styles.content}>
<h2>{categories[currentCategoryIndex]}</h2>
{renderCategory()}
</div>

View File

@ -1,581 +0,0 @@
import {
FriendRequestAction,
GameRunning,
UserGame,
UserProfile,
} from "@types";
import cn from "classnames";
import * as styles from "./user.css";
import { SPACING_UNIT, vars } from "@renderer/theme.css";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
import {
useAppSelector,
useDate,
useToast,
useUserDetails,
} from "@renderer/hooks";
import { useNavigate } from "react-router-dom";
import {
buildGameDetailsPath,
profileBackgroundFromProfileImage,
steamUrlBuilder,
} from "@renderer/helpers";
import {
CheckCircleIcon,
PersonIcon,
PlusIcon,
TelescopeIcon,
XCircleIcon,
} from "@primer/octicons-react";
import { Button, Link } from "@renderer/components";
import { UserProfileSettingsModal } from "./user-profile-settings-modal";
import { UserSignOutModal } from "./user-sign-out-modal";
import { UserFriendModalTab } from "../shared-modals/user-friend-modal";
import { UserBlockModal } from "./user-block-modal";
import { UserConfirmUndoFriendshipModal } from "./user-confirm-undo-friendship-modal";
const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120;
export interface ProfileContentProps {
userProfile: UserProfile;
updateUserProfile: () => Promise<void>;
}
type FriendAction = FriendRequestAction | ("BLOCK" | "UNDO" | "SEND");
export function UserContent({
userProfile,
updateUserProfile,
}: ProfileContentProps) {
const { t, i18n } = useTranslation("user_profile");
const {
userDetails,
profileBackground,
signOut,
sendFriendRequest,
fetchFriendRequests,
showFriendsModal,
updateFriendRequestState,
undoFriendship,
blockUser,
} = useUserDetails();
const { showSuccessToast, showErrorToast } = useToast();
const [profileContentBoxBackground, setProfileContentBoxBackground] =
useState<string | undefined>();
const [showProfileSettingsModal, setShowProfileSettingsModal] =
useState(false);
const [showSignOutModal, setShowSignOutModal] = useState(false);
const [showUserBlockModal, setShowUserBlockModal] = useState(false);
const [showUndoFriendshipModal, setShowUndoFriendshipModal] = useState(false);
const [currentGame, setCurrentGame] = useState<GameRunning | null>(null);
const { gameRunning } = useAppSelector((state) => state.gameRunning);
const navigate = useNavigate();
const numberFormatter = useMemo(() => {
return new Intl.NumberFormat(i18n.language, {
maximumFractionDigits: 0,
});
}, [i18n.language]);
const { formatDistance, formatDiffInMillis } = useDate();
const formatPlayTime = () => {
const seconds = userProfile.totalPlayTimeInSeconds;
const minutes = seconds / 60;
if (minutes < MAX_MINUTES_TO_SHOW_IN_PLAYTIME) {
return t("amount_minutes", {
amount: minutes.toFixed(0),
});
}
const hours = minutes / 60;
return t("amount_hours", { amount: numberFormatter.format(hours) });
};
const handleGameClick = (game: UserGame) => {
navigate(buildGameDetailsPath(game));
};
const handleEditProfile = () => {
setShowProfileSettingsModal(true);
};
const handleOnClickFriend = (userId: string) => {
navigate(`/user/${userId}`);
};
const handleConfirmSignout = async () => {
await signOut();
showSuccessToast(t("successfully_signed_out"));
navigate("/");
};
const isMe = userDetails?.id == userProfile.id;
useEffect(() => {
if (isMe && gameRunning) {
setCurrentGame(gameRunning);
return;
}
setCurrentGame(userProfile.currentGame);
}, [gameRunning, isMe, userProfile.currentGame]);
useEffect(() => {
if (isMe) fetchFriendRequests();
}, [isMe, fetchFriendRequests]);
useEffect(() => {
if (isMe && profileBackground) {
setProfileContentBoxBackground(profileBackground);
}
if (userProfile.profileImageUrl) {
profileBackgroundFromProfileImage(userProfile.profileImageUrl).then(
(profileBackground) => {
setProfileContentBoxBackground(profileBackground);
}
);
}
}, [profileBackground, isMe, userProfile.profileImageUrl]);
const handleFriendAction = (userId: string, action: FriendAction) => {
try {
if (action === "UNDO") {
undoFriendship(userId).then(updateUserProfile);
return;
}
if (action === "BLOCK") {
blockUser(userId).then(() => {
setShowUserBlockModal(false);
showSuccessToast(t("user_blocked_successfully"));
navigate(-1);
});
return;
}
if (action === "SEND") {
sendFriendRequest(userProfile.id).then(updateUserProfile);
return;
}
updateFriendRequestState(userId, action).then(updateUserProfile);
} catch (err) {
showErrorToast(t("try_again"));
}
};
const showFriends = isMe || userProfile.totalFriends > 0;
const showProfileContent =
isMe ||
userProfile.profileVisibility === "PUBLIC" ||
(userProfile.relation?.status === "ACCEPTED" &&
userProfile.profileVisibility === "FRIENDS");
const getProfileActions = () => {
if (isMe) {
return (
<>
<Button theme="outline" onClick={handleEditProfile}>
{t("settings")}
</Button>
<Button theme="danger" onClick={() => setShowSignOutModal(true)}>
{t("sign_out")}
</Button>
</>
);
}
if (userProfile.relation == null) {
return (
<>
<Button
theme="outline"
onClick={() => handleFriendAction(userProfile.id, "SEND")}
>
{t("add_friend")}
</Button>
<Button theme="danger" onClick={() => setShowUserBlockModal(true)}>
{t("block_user")}
</Button>
</>
);
}
if (userProfile.relation.status === "ACCEPTED") {
return (
<>
<Button
theme="outline"
className={styles.cancelRequestButton}
onClick={() => setShowUndoFriendshipModal(true)}
>
<XCircleIcon size={28} /> {t("undo_friendship")}
</Button>
</>
);
}
if (userProfile.relation.BId === userProfile.id) {
return (
<Button
theme="outline"
className={styles.cancelRequestButton}
onClick={() =>
handleFriendAction(userProfile.relation!.BId, "CANCEL")
}
>
<XCircleIcon size={28} /> {t("cancel_request")}
</Button>
);
}
return (
<>
<Button
theme="outline"
className={styles.acceptRequestButton}
onClick={() =>
handleFriendAction(userProfile.relation!.AId, "ACCEPTED")
}
>
<CheckCircleIcon size={28} /> {t("accept_request")}
</Button>
<Button
theme="outline"
className={styles.cancelRequestButton}
onClick={() =>
handleFriendAction(userProfile.relation!.AId, "REFUSED")
}
>
<XCircleIcon size={28} /> {t("ignore_request")}
</Button>
</>
);
};
return (
<>
<UserProfileSettingsModal
visible={showProfileSettingsModal}
onClose={() => setShowProfileSettingsModal(false)}
updateUserProfile={updateUserProfile}
userProfile={userProfile}
/>
<UserSignOutModal
visible={showSignOutModal}
onClose={() => setShowSignOutModal(false)}
onConfirm={handleConfirmSignout}
/>
<UserBlockModal
visible={showUserBlockModal}
onClose={() => setShowUserBlockModal(false)}
onConfirm={() => handleFriendAction(userProfile.id, "BLOCK")}
displayName={userProfile.displayName}
/>
<UserConfirmUndoFriendshipModal
visible={showUndoFriendshipModal}
onClose={() => setShowUndoFriendshipModal(false)}
onConfirm={() => handleFriendAction(userProfile.id, "UNDO")}
displayName={userProfile.displayName}
/>
<section
className={styles.profileContentBox}
style={{
padding: `${SPACING_UNIT * 3}px ${SPACING_UNIT * 2}px`,
position: "relative",
}}
>
{currentGame && (
<img
src={steamUrlBuilder.libraryHero(currentGame.objectID)}
alt={currentGame.title}
className={styles.profileBackground}
/>
)}
<div
style={{
background: profileContentBoxBackground,
position: "absolute",
inset: 0,
borderRadius: "4px",
}}
></div>
<div className={styles.profileAvatarContainer}>
{userProfile.profileImageUrl ? (
<img
className={styles.profileAvatar}
alt={userProfile.displayName}
src={userProfile.profileImageUrl}
/>
) : (
<PersonIcon size={72} />
)}
</div>
<div className={styles.profileInformation}>
<h2 className={styles.profileDisplayName}>
{userProfile.displayName}
</h2>
{currentGame && (
<div
style={{
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT / 2}px`,
}}
>
<div
style={{
display: "flex",
flexDirection: "row",
gap: `${SPACING_UNIT}px`,
alignItems: "center",
}}
>
<Link to={buildGameDetailsPath(currentGame)}>
{currentGame.title}
</Link>
</div>
<small>
{t("playing_for", {
amount: formatDiffInMillis(
currentGame.sessionDurationInMillis,
new Date()
),
})}
</small>
</div>
)}
</div>
<div
style={{
flex: 1,
display: "flex",
justifyContent: "end",
zIndex: 1,
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT}px`,
}}
>
{getProfileActions()}
</div>
</div>
</section>
{showProfileContent && (
<div className={styles.profileContent}>
<div className={styles.profileGameSection}>
<h2>{t("activity")}</h2>
{!userProfile.recentGames.length ? (
<div className={styles.noDownloads}>
<div className={styles.telescopeIcon}>
<TelescopeIcon size={24} />
</div>
<h2>{t("no_recent_activity_title")}</h2>
{isMe && <p>{t("no_recent_activity_description")}</p>}
</div>
) : (
<div
style={{
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT * 2}px`,
}}
>
{userProfile.recentGames.map((game) => (
<button
key={game.objectID}
className={cn(styles.feedItem, styles.profileContentBox)}
onClick={() => handleGameClick(game)}
>
<img
className={styles.feedGameIcon}
src={game.cover}
alt={game.title}
/>
<div className={styles.gameInformation}>
<h4>{game.title}</h4>
<small>
{t("last_time_played", {
period: formatDistance(
game.lastTimePlayed!,
new Date(),
{
addSuffix: true,
}
),
})}
</small>
</div>
</button>
))}
</div>
)}
</div>
<div className={styles.contentSidebar}>
<div className={styles.profileGameSection}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: `${SPACING_UNIT * 2}px`,
}}
>
<h2>{t("library")}</h2>
<div
style={{
flex: 1,
backgroundColor: vars.color.border,
height: "1px",
}}
/>
<h3 style={{ fontWeight: "400" }}>
{userProfile.libraryGames.length}
</h3>
</div>
<small>
{t("total_play_time", { amount: formatPlayTime() })}
</small>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(4, 1fr)",
gap: `${SPACING_UNIT}px`,
}}
>
{userProfile.libraryGames.map((game) => (
<button
key={game.objectID}
className={cn(
styles.gameListItem,
styles.profileContentBox
)}
onClick={() => handleGameClick(game)}
title={game.title}
>
{game.iconUrl ? (
<img
className={styles.libraryGameIcon}
src={game.iconUrl}
alt={game.title}
/>
) : (
<SteamLogo className={styles.libraryGameIcon} />
)}
</button>
))}
</div>
</div>
{showFriends && (
<div className={styles.friendsSection}>
<button
className={styles.friendsSectionHeader}
onClick={() =>
showFriendsModal(
UserFriendModalTab.FriendsList,
userProfile.id
)
}
>
<h2>{t("friends")}</h2>
<div
style={{
flex: 1,
backgroundColor: vars.color.border,
height: "1px",
}}
/>
<h3 style={{ fontWeight: "400" }}>
{userProfile.totalFriends}
</h3>
</button>
<div
style={{
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT}px`,
}}
>
{userProfile.friends.map((friend) => {
return (
<button
key={friend.id}
className={cn(
styles.profileContentBox,
styles.friendListContainer
)}
onClick={() => handleOnClickFriend(friend.id)}
>
<div className={styles.friendAvatarContainer}>
{friend.profileImageUrl ? (
<img
className={styles.friendProfileIcon}
src={friend.profileImageUrl}
alt={friend.displayName}
/>
) : (
<PersonIcon size={24} />
)}
</div>
<p className={styles.friendListDisplayName}>
{friend.displayName}
</p>
</button>
);
})}
{isMe && (
<Button
theme="outline"
onClick={() =>
showFriendsModal(
UserFriendModalTab.AddFriend,
userProfile.id
)
}
>
<PlusIcon /> {t("add")}
</Button>
)}
</div>
</div>
)}
</div>
</div>
)}
</>
);
}

View File

@ -1,54 +0,0 @@
import { UserProfile } from "@types";
import { useCallback, useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { setHeaderTitle } from "@renderer/features";
import { useAppDispatch, useToast } from "@renderer/hooks";
import { UserSkeleton } from "./user-skeleton";
import { UserContent } from "./user-content";
import { SkeletonTheme } from "react-loading-skeleton";
import { vars } from "@renderer/theme.css";
import * as styles from "./user.css";
import { useTranslation } from "react-i18next";
export const User = () => {
const { userId } = useParams();
const [userProfile, setUserProfile] = useState<UserProfile>();
const navigate = useNavigate();
const { t } = useTranslation("user_profile");
const { showErrorToast } = useToast();
const dispatch = useAppDispatch();
const getUserProfile = useCallback(() => {
return window.electron.getUser(userId!).then((userProfile) => {
if (userProfile) {
dispatch(setHeaderTitle(userProfile.displayName));
setUserProfile(userProfile);
} else {
showErrorToast(t("user_not_found"));
navigate(-1);
}
});
}, [dispatch, navigate, showErrorToast, userId, t]);
useEffect(() => {
getUserProfile();
}, [getUserProfile]);
return (
<SkeletonTheme baseColor={vars.color.background} highlightColor="#444">
<div className={styles.wrapper}>
{userProfile ? (
<UserContent
userProfile={userProfile}
updateUserProfile={getUserProfile}
/>
) : (
<UserSkeleton />
)}
</div>
</SkeletonTheme>
);
};

View File

@ -98,3 +98,16 @@ export const getDownloadersForUris = (uris: string[]) => {
return Array.from(downloadersSet);
};
export const steamUrlBuilder = {
library: (objectID: string) =>
`https://steamcdn-a.akamaihd.net/steam/apps/${objectID}/header.jpg`,
libraryHero: (objectID: string) =>
`https://steamcdn-a.akamaihd.net/steam/apps/${objectID}/library_hero.jpg`,
logo: (objectID: string) =>
`https://cdn.cloudflare.steamstatic.com/steam/apps/${objectID}/logo.png`,
cover: (objectID: string) =>
`https://cdn.cloudflare.steamstatic.com/steam/apps/${objectID}/library_600x900.jpg`,
icon: (objectID: string, clientIcon: string) =>
`https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/${objectID}/${clientIcon}.ico`,
};

View File

@ -99,7 +99,7 @@ export interface CatalogueEntry {
}
export interface UserGame {
objectID: string;
objectId: string;
shop: GameShop;
title: string;
iconUrl: string | null;
@ -219,7 +219,7 @@ export interface RealDebridUnrestrictLink {
export interface RealDebridAddMagnet {
id: string;
// URL of the created ressource
// URL of the created resource
uri: string;
}
@ -280,6 +280,8 @@ export interface UserFriend {
id: string;
displayName: string;
profileImageUrl: string | null;
createdAt: string;
updatedAt: string;
}
export interface UserFriends {
@ -307,6 +309,10 @@ export interface UserRelation {
updatedAt: string;
}
export interface UserProfileCurrentGame extends GameRunning {
sessionDurationInSeconds: number;
}
export interface UserProfile {
id: string;
displayName: string;
@ -318,10 +324,10 @@ export interface UserProfile {
friends: UserFriend[];
totalFriends: number;
relation: UserRelation | null;
currentGame: GameRunning | null;
currentGame: UserProfileCurrentGame | null;
}
export interface UpdateProfileProps {
export interface UpdateProfileRequest {
displayName?: string;
profileVisibility?: "PUBLIC" | "PRIVATE" | "FRIENDS";
profileImageUrl?: string | null;

View File

@ -579,6 +579,13 @@
"@types/conventional-commits-parser" "^5.0.0"
chalk "^5.3.0"
"@cspotcode/source-map-support@^0.8.0":
version "0.8.1"
resolved "https://registry.yarnpkg.com/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz#00629c35a688e05a88b1cda684fb9d5e73f000a1"
integrity sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==
dependencies:
"@jridgewell/trace-mapping" "0.3.9"
"@develar/schema-utils@~2.6.5":
version "2.6.5"
resolved "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz"
@ -1003,7 +1010,7 @@
"@jridgewell/sourcemap-codec" "^1.4.10"
"@jridgewell/trace-mapping" "^0.3.24"
"@jridgewell/resolve-uri@^3.1.0":
"@jridgewell/resolve-uri@^3.0.3", "@jridgewell/resolve-uri@^3.1.0":
version "3.1.2"
resolved "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz"
integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==
@ -1018,6 +1025,14 @@
resolved "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz"
integrity sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==
"@jridgewell/trace-mapping@0.3.9":
version "0.3.9"
resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz#6534fd5933a53ba7cbf3a17615e273a0d1273ff9"
integrity sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==
dependencies:
"@jridgewell/resolve-uri" "^3.0.3"
"@jridgewell/sourcemap-codec" "^1.4.10"
"@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25":
version "0.3.25"
resolved "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz"
@ -1876,6 +1891,26 @@
resolved "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz"
integrity sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==
"@tsconfig/node10@^1.0.7":
version "1.0.11"
resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.11.tgz#6ee46400685f130e278128c7b38b7e031ff5b2f2"
integrity sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==
"@tsconfig/node12@^1.0.7":
version "1.0.11"
resolved "https://registry.yarnpkg.com/@tsconfig/node12/-/node12-1.0.11.tgz#ee3def1f27d9ed66dac6e46a295cffb0152e058d"
integrity sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==
"@tsconfig/node14@^1.0.0":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@tsconfig/node14/-/node14-1.0.3.tgz#e4386316284f00b98435bf40f72f75a09dabf6c1"
integrity sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==
"@tsconfig/node16@^1.0.2":
version "1.0.4"
resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.4.tgz#0b92dcc0cc1c81f6f306a381f28e31b1a56536e9"
integrity sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==
"@types/accepts@*":
version "1.3.7"
resolved "https://registry.yarnpkg.com/@types/accepts/-/accepts-1.3.7.tgz#3b98b1889d2b2386604c2bbbe62e4fb51e95b265"
@ -2521,6 +2556,18 @@ acorn-jsx@^5.3.2:
resolved "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz"
integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==
acorn-walk@^8.1.1:
version "8.3.3"
resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.3.3.tgz#9caeac29eefaa0c41e3d4c65137de4d6f34df43e"
integrity sha512-MxXdReSRhGO7VlFe1bRG/oI7/mdLV9B9JJT0N8vZOhF7gFRR5l3M8W9G8JxmKV+JC5mGqJ0QvqfSOLsCPa4nUw==
dependencies:
acorn "^8.11.0"
acorn@^8.11.0, acorn@^8.4.1:
version "8.12.1"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.12.1.tgz#71616bdccbe25e27a54439e0046e89ca76df2248"
integrity sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==
acorn@^8.11.3, acorn@^8.9.0:
version "8.11.3"
resolved "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz"
@ -2660,6 +2707,11 @@ applescript@^1.0.0:
resolved "https://registry.npmjs.org/applescript/-/applescript-1.0.0.tgz"
integrity sha512-yvtNHdWvtbYEiIazXAdp/NY+BBb65/DAseqlNiJQjOx9DynuzOYDbVLBJvuc0ve0VL9x6B3OHF6eH52y9hCBtQ==
arg@^4.1.0:
version "4.1.3"
resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089"
integrity sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==
argparse@^2.0.1:
version "2.0.1"
resolved "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz"
@ -3373,6 +3425,11 @@ create-desktop-shortcuts@^1.11.0:
dependencies:
which "2.0.2"
create-require@^1.1.0:
version "1.1.1"
resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333"
integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==
cross-fetch-ponyfill@^1.0.3:
version "1.0.3"
resolved "https://registry.npmjs.org/cross-fetch-ponyfill/-/cross-fetch-ponyfill-1.0.3.tgz"
@ -3576,6 +3633,11 @@ detect-node@^2.0.4:
resolved "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz"
integrity sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==
diff@^4.0.1:
version "4.0.2"
resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d"
integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==
dir-compare@^3.0.0:
version "3.3.0"
resolved "https://registry.npmjs.org/dir-compare/-/dir-compare-3.3.0.tgz"
@ -5617,6 +5679,11 @@ magnet-uri@^7.0.5:
bep53-range "^2.0.0"
uint8-util "^2.1.9"
make-error@^1.1.1:
version "1.3.6"
resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2"
integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==
matcher@^3.0.0:
version "3.0.0"
resolved "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz"
@ -7273,6 +7340,25 @@ ts-api-utils@^1.0.1:
resolved "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz"
integrity sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==
ts-node@^10.9.2:
version "10.9.2"
resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-10.9.2.tgz#70f021c9e185bccdca820e26dc413805c101c71f"
integrity sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==
dependencies:
"@cspotcode/source-map-support" "^0.8.0"
"@tsconfig/node10" "^1.0.7"
"@tsconfig/node12" "^1.0.7"
"@tsconfig/node14" "^1.0.0"
"@tsconfig/node16" "^1.0.2"
acorn "^8.4.1"
acorn-walk "^8.1.1"
arg "^4.1.0"
create-require "^1.1.0"
diff "^4.0.1"
make-error "^1.1.1"
v8-compile-cache-lib "^3.0.1"
yn "3.1.1"
tslib@^2.0.3, tslib@^2.5.0, tslib@^2.6.2:
version "2.6.2"
resolved "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz"
@ -7484,6 +7570,11 @@ uuid@^9.0.0:
resolved "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz"
integrity sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==
v8-compile-cache-lib@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz#6336e8d71965cb3d35a1bbb7868445a7c05264bf"
integrity sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==
verror@^1.10.0:
version "1.10.1"
resolved "https://registry.yarnpkg.com/verror/-/verror-1.10.1.tgz#4bf09eeccf4563b109ed4b3d458380c972b0cdeb"
@ -7777,6 +7868,11 @@ yauzl@^2.10.0:
buffer-crc32 "~0.2.3"
fd-slicer "~1.1.0"
yn@3.1.1:
version "3.1.1"
resolved "https://registry.yarnpkg.com/yn/-/yn-3.1.1.tgz#1e87401a09d767c1d5eab26a6e4c185182d2eb50"
integrity sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==
yocto-queue@^0.1.0:
version "0.1.0"
resolved "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz"