mirror of
https://github.com/hydralauncher/hydra.git
synced 2025-03-09 15:40:26 +00:00
feat: initial profile refactor
This commit is contained in:
parent
6273ca1376
commit
ada7b452a0
48 changed files with 10733 additions and 922 deletions
|
@ -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 });
|
||||
|
|
|
@ -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: [],
|
||||
});
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 });
|
||||
};
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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));
|
||||
|
||||
|
|
|
@ -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 },
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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) =>
|
||||
|
|
|
@ -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]);
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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",
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
@ -1,2 +1,3 @@
|
|||
export * from "./game-details/game-details.context";
|
||||
export * from "./settings/settings.context";
|
||||
export * from "./user-profile/user-profile.context";
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
5
src/renderer/src/declaration.d.ts
vendored
5
src/renderer/src/declaration.d.ts
vendored
|
@ -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,
|
||||
|
|
|
@ -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)})`;
|
||||
};
|
||||
|
|
0
src/renderer/src/hooks/use-friendship.ts
Normal file
0
src/renderer/src/hooks/use-friendship.ts
Normal 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]
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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";
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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";
|
||||
|
|
73
src/renderer/src/pages/profile/profile-content.css.ts
Normal file
73
src/renderer/src/pages/profile/profile-content.css.ts
Normal 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,
|
||||
});
|
172
src/renderer/src/pages/profile/profile-content.tsx
Normal file
172
src/renderer/src/pages/profile/profile-content.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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%",
|
||||
});
|
191
src/renderer/src/pages/profile/profile-hero/profile-hero.tsx
Normal file
191
src/renderer/src/pages/profile/profile-hero/profile-hero.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
}
|
|
@ -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",
|
29
src/renderer/src/pages/profile/profile.tsx
Normal file
29
src/renderer/src/pages/profile/profile.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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 {
|
|
@ -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 {
|
|
@ -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]);
|
|
@ -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 {
|
|
@ -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`,
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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`,
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue