feat: enabling gif upload

This commit is contained in:
Chubby Granny Chaser 2024-10-16 10:46:17 +01:00
parent 05653500b6
commit 05625e7594
No known key found for this signature in database
36 changed files with 403 additions and 373 deletions

View file

@ -1,14 +0,0 @@
import { registerEvent } from "../register-event";
import { GameShop } from "@types";
import { Ludusavi } from "@main/services";
const checkGameCloudSyncSupport = async (
_event: Electron.IpcMainInvokeEvent,
objectId: string,
shop: GameShop
) => {
const games = await Ludusavi.findGames(shop, objectId);
return games.length === 1;
};
registerEvent("checkGameCloudSyncSupport", checkGameCloudSyncSupport);

View file

@ -61,7 +61,6 @@ import "./cloud-save/download-game-artifact";
import "./cloud-save/get-game-artifacts"; import "./cloud-save/get-game-artifacts";
import "./cloud-save/get-game-backup-preview"; import "./cloud-save/get-game-backup-preview";
import "./cloud-save/upload-save-game"; import "./cloud-save/upload-save-game";
import "./cloud-save/check-game-cloud-sync-support";
import "./cloud-save/delete-game-artifact"; import "./cloud-save/delete-game-artifact";
import "./notifications/publish-new-repacks-notification"; import "./notifications/publish-new-repacks-notification";
import { isPortableVersion } from "@main/helpers"; import { isPortableVersion } from "@main/helpers";

View file

@ -1,56 +1,75 @@
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { HydraApi, PythonInstance } from "@main/services"; import { HydraApi } from "@main/services";
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import type { UpdateProfileRequest, UserProfile } from "@types"; import type { UpdateProfileRequest, UserProfile } from "@types";
import { omit } from "lodash-es"; import { omit } from "lodash-es";
import axios from "axios"; import axios from "axios";
import { fileTypeFromFile } from "file-type";
interface PresignedResponse {
presignedUrl: string;
profileImageUrl: string;
}
const patchUserProfile = async (updateProfile: UpdateProfileRequest) => { const patchUserProfile = async (updateProfile: UpdateProfileRequest) => {
return HydraApi.patch<UserProfile>("/profile", updateProfile); return HydraApi.patch<UserProfile>("/profile", updateProfile);
}; };
const getNewProfileImageUrl = async (localImageUrl: string) => { const uploadImage = async (
const { imagePath, mimeType } = type: "profile-image" | "background-image",
await PythonInstance.processProfileImage(localImageUrl); imagePath: string
) => {
const stats = fs.statSync(imagePath); const stat = fs.statSync(imagePath);
const fileBuffer = fs.readFileSync(imagePath); const fileBuffer = fs.readFileSync(imagePath);
const fileSizeInBytes = stats.size; const fileSizeInBytes = stat.size;
const { presignedUrl, profileImageUrl } = const response = await HydraApi.post<{ presignedUrl: string }>(
await HydraApi.post<PresignedResponse>(`/presigned-urls/profile-image`, { `/presigned-urls/${type}`,
{
imageExt: path.extname(imagePath).slice(1), imageExt: path.extname(imagePath).slice(1),
imageLength: fileSizeInBytes, imageLength: fileSizeInBytes,
}); }
);
await axios.put(presignedUrl, fileBuffer, { const mimeType = await fileTypeFromFile(imagePath);
await axios.put(response.presignedUrl, fileBuffer, {
headers: { headers: {
"Content-Type": mimeType, "Content-Type": mimeType?.mime,
}, },
}); });
return profileImageUrl; if (type === "background-image") {
return response["backgroundImageUrl"];
}
return response["profileImageUrl"];
}; };
const updateProfile = async ( const updateProfile = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
updateProfile: UpdateProfileRequest updateProfile: UpdateProfileRequest
) => { ) => {
if (!updateProfile.profileImageUrl) { const payload = omit(updateProfile, [
return patchUserProfile(omit(updateProfile, "profileImageUrl")); "profileImageUrl",
"backgroundImageUrl",
]);
if (updateProfile.profileImageUrl) {
const profileImageUrl = await uploadImage(
"profile-image",
updateProfile.profileImageUrl
).catch(() => undefined);
payload["profileImageUrl"] = profileImageUrl;
} }
const profileImageUrl = await getNewProfileImageUrl( if (updateProfile.backgroundImageUrl) {
updateProfile.profileImageUrl const backgroundImageUrl = await uploadImage(
).catch(() => undefined); "background-image",
updateProfile.backgroundImageUrl
).catch(() => undefined);
return patchUserProfile({ ...updateProfile, profileImageUrl }); payload["backgroundImageUrl"] = backgroundImageUrl;
}
return patchUserProfile(payload);
}; };
registerEvent("updateProfile", updateProfile); registerEvent("updateProfile", updateProfile);

View file

@ -160,8 +160,6 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("getGameArtifacts", objectId, shop), ipcRenderer.invoke("getGameArtifacts", objectId, shop),
getGameBackupPreview: (objectId: string, shop: GameShop) => getGameBackupPreview: (objectId: string, shop: GameShop) =>
ipcRenderer.invoke("getGameBackupPreview", objectId, shop), ipcRenderer.invoke("getGameBackupPreview", objectId, shop),
checkGameCloudSyncSupport: (objectId: string, shop: GameShop) =>
ipcRenderer.invoke("checkGameCloudSyncSupport", objectId, shop),
deleteGameArtifact: (gameArtifactId: string) => deleteGameArtifact: (gameArtifactId: string) =>
ipcRenderer.invoke("deleteGameArtifact", gameArtifactId), ipcRenderer.invoke("deleteGameArtifact", gameArtifactId),
onUploadComplete: (objectId: string, shop: GameShop, cb: () => void) => { onUploadComplete: (objectId: string, shop: GameShop, cb: () => void) => {

View file

@ -0,0 +1,23 @@
import { style } from "@vanilla-extract/css";
import { vars } from "../../theme.css";
export const profileAvatar = style({
borderRadius: "4px",
display: "flex",
justifyContent: "center",
alignItems: "center",
backgroundColor: vars.color.background,
border: `solid 1px ${vars.color.border}`,
cursor: "pointer",
color: vars.color.muted,
position: "relative",
});
export const profileAvatarImage = style({
height: "100%",
width: "100%",
objectFit: "cover",
overflow: "hidden",
borderRadius: "4px",
});

View file

@ -0,0 +1,32 @@
import { PersonIcon } from "@primer/octicons-react";
import * as styles from "./avatar.css";
export interface AvatarProps
extends Omit<
React.DetailedHTMLProps<
React.ImgHTMLAttributes<HTMLImageElement>,
HTMLImageElement
>,
"src"
> {
size: number;
src?: string | null;
}
export function Avatar({ size, alt, src, ...props }: AvatarProps) {
return (
<div className={styles.profileAvatar} style={{ width: size, height: size }}>
{src ? (
<img
className={styles.profileAvatarImage}
alt={alt}
src={src}
{...props}
/>
) : (
<PersonIcon size={size * 0.7} />
)}
</div>
);
}

View file

@ -60,7 +60,12 @@ export function GameCard({ game, ...props }: GameCardProps) {
onMouseEnter={handleHover} onMouseEnter={handleHover}
> >
<div className={styles.backdrop}> <div className={styles.backdrop}>
<img src={game.cover} alt={game.title} className={styles.cover} /> <img
src={game.cover}
alt={game.title}
className={styles.cover}
loading="lazy"
/>
<div className={styles.content}> <div className={styles.content}>
<div className={styles.titleContainer}> <div className={styles.titleContainer}>

View file

@ -49,7 +49,12 @@ export function Hero() {
<div className={styles.content}> <div className={styles.content}>
{game.logo && ( {game.logo && (
<img src={game.logo} width="250px" alt={game.description} /> <img
src={game.logo}
width="250px"
alt={game.description}
loading="eager"
/>
)} )}
<p className={styles.description}>{game.description}</p> <p className={styles.description}>{game.description}</p>
</div> </div>

View file

@ -1,3 +1,4 @@
export * from "./avatar/avatar";
export * from "./bottom-panel/bottom-panel"; export * from "./bottom-panel/bottom-panel";
export * from "./button/button"; export * from "./button/button";
export * from "./game-card/game-card"; export * from "./game-card/game-card";
@ -12,3 +13,4 @@ export * from "./select-field/select-field";
export * from "./toast/toast"; export * from "./toast/toast";
export * from "./badge/badge"; export * from "./badge/badge";
export * from "./confirmation-modal/confirmation-modal"; export * from "./confirmation-modal/confirmation-modal";
export * from "./suspense-wrapper/suspense-wrapper";

View file

@ -31,19 +31,6 @@ export const profileButtonContent = style({
width: "100%", width: "100%",
}); });
export const profileAvatar = style({
width: "35px",
height: "35px",
borderRadius: "4px",
display: "flex",
justifyContent: "center",
alignItems: "center",
backgroundColor: vars.color.background,
border: `solid 1px ${vars.color.border}`,
position: "relative",
objectFit: "cover",
});
export const profileButtonInformation = style({ export const profileButtonInformation = style({
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",

View file

@ -1,11 +1,12 @@
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { PeopleIcon, PersonIcon } from "@primer/octicons-react"; import { PeopleIcon } from "@primer/octicons-react";
import * as styles from "./sidebar-profile.css"; import * as styles from "./sidebar-profile.css";
import { useAppSelector, useUserDetails } from "@renderer/hooks"; import { useAppSelector, useUserDetails } from "@renderer/hooks";
import { useEffect, useMemo, useRef } from "react"; import { useEffect, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal"; import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal";
import SteamLogo from "@renderer/assets/steam-logo.svg?react"; import SteamLogo from "@renderer/assets/steam-logo.svg?react";
import { Avatar } from "../avatar/avatar";
const LONG_POLLING_INTERVAL = 60_000; const LONG_POLLING_INTERVAL = 60_000;
@ -94,17 +95,11 @@ export function SidebarProfile() {
onClick={handleProfileClick} onClick={handleProfileClick}
> >
<div className={styles.profileButtonContent}> <div className={styles.profileButtonContent}>
<div className={styles.profileAvatar}> <Avatar
{userDetails?.profileImageUrl ? ( size={35}
<img src={userDetails?.profileImageUrl}
className={styles.profileAvatar} alt={userDetails?.displayName}
src={userDetails.profileImageUrl} />
alt={userDetails.displayName}
/>
) : (
<PersonIcon size={24} />
)}
</div>
<div className={styles.profileButtonInformation}> <div className={styles.profileButtonInformation}>
<p className={styles.profileButtonTitle}> <p className={styles.profileButtonTitle}>

View file

@ -225,6 +225,7 @@ export function Sidebar() {
className={styles.gameIcon} className={styles.gameIcon}
src={game.iconUrl} src={game.iconUrl}
alt={game.title} alt={game.title}
loading="lazy"
/> />
) : ( ) : (
<SteamLogo className={styles.gameIcon} /> <SteamLogo className={styles.gameIcon} />

View file

@ -0,0 +1,13 @@
import { Suspense } from "react";
export interface SuspenseWrapperProps {
Component: React.LazyExoticComponent<() => JSX.Element>;
}
export function SuspenseWrapper({ Component }: SuspenseWrapperProps) {
return (
<Suspense fallback={null}>
<Component />
</Suspense>
);
}

View file

@ -23,13 +23,13 @@ export interface CloudSyncContext {
artifacts: GameArtifact[]; artifacts: GameArtifact[];
showCloudSyncModal: boolean; showCloudSyncModal: boolean;
showCloudSyncFilesModal: boolean; showCloudSyncFilesModal: boolean;
supportsCloudSync: boolean | null;
backupState: CloudSyncState; backupState: CloudSyncState;
setShowCloudSyncModal: React.Dispatch<React.SetStateAction<boolean>>; setShowCloudSyncModal: React.Dispatch<React.SetStateAction<boolean>>;
downloadGameArtifact: (gameArtifactId: string) => Promise<void>; downloadGameArtifact: (gameArtifactId: string) => Promise<void>;
uploadSaveGame: () => Promise<void>; uploadSaveGame: () => Promise<void>;
deleteGameArtifact: (gameArtifactId: string) => Promise<void>; deleteGameArtifact: (gameArtifactId: string) => Promise<void>;
setShowCloudSyncFilesModal: React.Dispatch<React.SetStateAction<boolean>>; setShowCloudSyncFilesModal: React.Dispatch<React.SetStateAction<boolean>>;
getGameBackupPreview: () => Promise<void>;
restoringBackup: boolean; restoringBackup: boolean;
uploadingBackup: boolean; uploadingBackup: boolean;
} }
@ -37,7 +37,6 @@ export interface CloudSyncContext {
export const cloudSyncContext = createContext<CloudSyncContext>({ export const cloudSyncContext = createContext<CloudSyncContext>({
backupPreview: null, backupPreview: null,
showCloudSyncModal: false, showCloudSyncModal: false,
supportsCloudSync: null,
backupState: CloudSyncState.Unknown, backupState: CloudSyncState.Unknown,
setShowCloudSyncModal: () => {}, setShowCloudSyncModal: () => {},
downloadGameArtifact: async () => {}, downloadGameArtifact: async () => {},
@ -46,6 +45,7 @@ export const cloudSyncContext = createContext<CloudSyncContext>({
deleteGameArtifact: async () => {}, deleteGameArtifact: async () => {},
showCloudSyncFilesModal: false, showCloudSyncFilesModal: false,
setShowCloudSyncFilesModal: () => {}, setShowCloudSyncFilesModal: () => {},
getGameBackupPreview: async () => {},
restoringBackup: false, restoringBackup: false,
uploadingBackup: false, uploadingBackup: false,
}); });
@ -66,9 +66,6 @@ export function CloudSyncContextProvider({
}: CloudSyncContextProviderProps) { }: CloudSyncContextProviderProps) {
const { t } = useTranslation("game_details"); const { t } = useTranslation("game_details");
const [supportsCloudSync, setSupportsCloudSync] = useState<boolean | null>(
null
);
const [artifacts, setArtifacts] = useState<GameArtifact[]>([]); const [artifacts, setArtifacts] = useState<GameArtifact[]>([]);
const [showCloudSyncModal, setShowCloudSyncModal] = useState(false); const [showCloudSyncModal, setShowCloudSyncModal] = useState(false);
const [backupPreview, setBackupPreview] = useState<LudusaviBackup | null>( const [backupPreview, setBackupPreview] = useState<LudusaviBackup | null>(
@ -89,21 +86,26 @@ export function CloudSyncContextProvider({
); );
const getGameBackupPreview = useCallback(async () => { const getGameBackupPreview = useCallback(async () => {
window.electron.getGameArtifacts(objectId, shop).then((results) => { await Promise.allSettled([
setArtifacts(results); window.electron.getGameArtifacts(objectId, shop).then((results) => {
}); setArtifacts(results);
}),
window.electron window.electron
.getGameBackupPreview(objectId, shop) .getGameBackupPreview(objectId, shop)
.then((preview) => { .then((preview) => {
logger.info("Game backup preview", objectId, shop, preview); if (preview && Object.keys(preview.games).length) {
if (preview && Object.keys(preview.games).length) { setBackupPreview(preview);
setBackupPreview(preview); }
} })
}) .catch((err) => {
.catch((err) => { logger.error(
logger.error("Failed to get game backup preview", objectId, shop, err); "Failed to get game backup preview",
}); objectId,
shop,
err
);
}),
]);
}, [objectId, shop]); }, [objectId, shop]);
const uploadSaveGame = useCallback(async () => { const uploadSaveGame = useCallback(async () => {
@ -152,33 +154,14 @@ export function CloudSyncContextProvider({
[getGameBackupPreview] [getGameBackupPreview]
); );
useEffect(() => {
window.electron
.checkGameCloudSyncSupport(objectId, shop)
.then((result) => {
logger.info("Cloud sync support", objectId, shop, result);
setSupportsCloudSync(result);
})
.catch((err) => {
logger.error("Failed to check cloud sync support", err);
});
}, [objectId, shop, getGameBackupPreview]);
useEffect(() => { useEffect(() => {
setBackupPreview(null); setBackupPreview(null);
setArtifacts([]); setArtifacts([]);
setSupportsCloudSync(null);
setShowCloudSyncModal(false); setShowCloudSyncModal(false);
setRestoringBackup(false); setRestoringBackup(false);
setUploadingBackup(false); setUploadingBackup(false);
}, [objectId, shop]); }, [objectId, shop]);
useEffect(() => {
if (showCloudSyncModal) {
getGameBackupPreview();
}
}, [getGameBackupPreview, showCloudSyncModal]);
const backupState = useMemo(() => { const backupState = useMemo(() => {
if (!backupPreview) return CloudSyncState.Unknown; if (!backupPreview) return CloudSyncState.Unknown;
if (backupPreview.overall.changedGames.new) return CloudSyncState.New; if (backupPreview.overall.changedGames.new) return CloudSyncState.New;
@ -192,7 +175,6 @@ export function CloudSyncContextProvider({
return ( return (
<Provider <Provider
value={{ value={{
supportsCloudSync,
backupPreview, backupPreview,
showCloudSyncModal, showCloudSyncModal,
artifacts, artifacts,
@ -205,6 +187,7 @@ export function CloudSyncContextProvider({
downloadGameArtifact, downloadGameArtifact,
deleteGameArtifact, deleteGameArtifact,
setShowCloudSyncFilesModal, setShowCloudSyncFilesModal,
getGameBackupPreview,
}} }}
> >
{children} {children}

View file

@ -13,8 +13,9 @@ export interface UserProfileContext {
/* Indicates if the current user is viewing their own profile */ /* Indicates if the current user is viewing their own profile */
isMe: boolean; isMe: boolean;
userStats: UserStats | null; userStats: UserStats | null;
getUserProfile: () => Promise<void>; getUserProfile: () => Promise<void>;
setSelectedBackgroundImage: React.Dispatch<React.SetStateAction<string>>;
backgroundImage: string;
} }
export const DEFAULT_USER_PROFILE_BACKGROUND = "#151515B3"; export const DEFAULT_USER_PROFILE_BACKGROUND = "#151515B3";
@ -25,6 +26,8 @@ export const userProfileContext = createContext<UserProfileContext>({
isMe: false, isMe: false,
userStats: null, userStats: null,
getUserProfile: async () => {}, getUserProfile: async () => {},
setSelectedBackgroundImage: () => {},
backgroundImage: "",
}); });
const { Provider } = userProfileContext; const { Provider } = userProfileContext;
@ -47,6 +50,9 @@ export function UserProfileContextProvider({
const [heroBackground, setHeroBackground] = useState( const [heroBackground, setHeroBackground] = useState(
DEFAULT_USER_PROFILE_BACKGROUND DEFAULT_USER_PROFILE_BACKGROUND
); );
const [selectedBackgroundImage, setSelectedBackgroundImage] = useState("");
const isMe = userDetails?.id === userProfile?.id;
const getHeroBackgroundFromImageUrl = async (imageUrl: string) => { const getHeroBackgroundFromImageUrl = async (imageUrl: string) => {
const output = await average(imageUrl, { const output = await average(imageUrl, {
@ -57,6 +63,14 @@ export function UserProfileContextProvider({
return `linear-gradient(135deg, ${darkenColor(output as string, 0.5)}, ${darkenColor(output as string, 0.6, 0.5)})`; return `linear-gradient(135deg, ${darkenColor(output as string, 0.5)}, ${darkenColor(output as string, 0.6, 0.5)})`;
}; };
const getBackgroundImageUrl = () => {
if (selectedBackgroundImage && isMe)
return `local:${selectedBackgroundImage}`;
if (userProfile?.backgroundImageUrl) return userProfile.backgroundImageUrl;
return "";
};
const { t } = useTranslation("user_profile"); const { t } = useTranslation("user_profile");
const { showErrorToast } = useToast(); const { showErrorToast } = useToast();
@ -99,8 +113,10 @@ export function UserProfileContextProvider({
value={{ value={{
userProfile, userProfile,
heroBackground, heroBackground,
isMe: userDetails?.id === userProfile?.id, isMe,
getUserProfile, getUserProfile,
setSelectedBackgroundImage,
backgroundImage: getBackgroundImageUrl(),
userStats, userStats,
}} }}
> >

View file

@ -138,10 +138,6 @@ declare global {
objectId: string, objectId: string,
shop: GameShop shop: GameShop
) => Promise<LudusaviBackup | null>; ) => Promise<LudusaviBackup | null>;
checkGameCloudSyncSupport: (
objectId: string,
shop: GameShop
) => Promise<boolean>;
deleteGameArtifact: (gameArtifactId: string) => Promise<{ ok: boolean }>; deleteGameArtifact: (gameArtifactId: string) => Promise<{ ok: boolean }>;
onBackupDownloadComplete: ( onBackupDownloadComplete: (
objectId: string, objectId: string,

View file

@ -15,15 +15,6 @@ import "@fontsource/noto-sans/700.css";
import "react-loading-skeleton/dist/skeleton.css"; import "react-loading-skeleton/dist/skeleton.css";
import { App } from "./app"; import { App } from "./app";
import {
Home,
Downloads,
GameDetails,
SearchResults,
Settings,
Catalogue,
Profile,
} from "@renderer/pages";
import { store } from "./store"; import { store } from "./store";
@ -33,6 +24,17 @@ import { AchievementNotification } from "./pages/achievement/notification/achiev
import "./workers"; import "./workers";
import { RepacksContextProvider } from "./context"; import { RepacksContextProvider } from "./context";
import { Achievement } from "./pages/achievement/achievements"; import { Achievement } from "./pages/achievement/achievements";
import { SuspenseWrapper } from "./components";
const Home = React.lazy(() => import("./pages/home/home"));
const GameDetails = React.lazy(
() => import("./pages/game-details/game-details")
);
const Downloads = React.lazy(() => import("./pages/downloads/downloads"));
const SearchResults = React.lazy(() => import("./pages/home/search-results"));
const Settings = React.lazy(() => import("./pages/settings/settings"));
const Catalogue = React.lazy(() => import("./pages/catalogue/catalogue"));
const Profile = React.lazy(() => import("./pages/profile/profile"));
Sentry.init({}); Sentry.init({});
@ -63,13 +65,31 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
<HashRouter> <HashRouter>
<Routes> <Routes>
<Route element={<App />}> <Route element={<App />}>
<Route path="/" Component={Home} /> <Route path="/" element={<SuspenseWrapper Component={Home} />} />
<Route path="/catalogue" Component={Catalogue} /> <Route
<Route path="/downloads" Component={Downloads} /> path="/catalogue"
<Route path="/game/:shop/:objectId" Component={GameDetails} /> element={<SuspenseWrapper Component={Catalogue} />}
<Route path="/search" Component={SearchResults} /> />
<Route path="/settings" Component={Settings} /> <Route
<Route path="/profile/:userId" Component={Profile} /> path="/downloads"
element={<SuspenseWrapper Component={Downloads} />}
/>
<Route
path="/game/:shop/:objectId"
element={<SuspenseWrapper Component={GameDetails} />}
/>
<Route
path="/search"
element={<SuspenseWrapper Component={SearchResults} />}
/>
<Route
path="/settings"
element={<SuspenseWrapper Component={Settings} />}
/>
<Route
path="/profile/:userId"
element={<SuspenseWrapper Component={Profile} />}
/>
<Route path="/achievements" Component={Achievement} /> <Route path="/achievements" Component={Achievement} />
</Route> </Route>
<Route <Route

View file

@ -14,7 +14,7 @@ import { buildGameDetailsPath } from "@renderer/helpers";
import { SPACING_UNIT, vars } from "@renderer/theme.css"; import { SPACING_UNIT, vars } from "@renderer/theme.css";
export function Catalogue() { export default function Catalogue() {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { t } = useTranslation("catalogue"); const { t } = useTranslation("catalogue");

View file

@ -11,7 +11,7 @@ import { LibraryGame } from "@types";
import { orderBy } from "lodash-es"; import { orderBy } from "lodash-es";
import { ArrowDownIcon } from "@primer/octicons-react"; import { ArrowDownIcon } from "@primer/octicons-react";
export function Downloads() { export default function Downloads() {
const { library, updateLibrary } = useLibrary(); const { library, updateLibrary } = useLibrary();
const { t } = useTranslation("downloads"); const { t } = useTranslation("downloads");

View file

@ -1,4 +1,4 @@
import { useContext, useEffect, useRef, useState } from "react"; import { useContext, useEffect, useMemo, useRef, useState } from "react";
import { average } from "color.js"; import { average } from "color.js";
import Color from "color"; import Color from "color";
@ -13,7 +13,7 @@ import { cloudSyncContext, gameDetailsContext } from "@renderer/context";
import { steamUrlBuilder } from "@shared"; import { steamUrlBuilder } from "@shared";
import Lottie from "lottie-react"; import Lottie from "lottie-react";
import downloadingAnimation from "@renderer/assets/lottie/cloud.json"; import cloudAnimation from "@renderer/assets/lottie/cloud.json";
import { useUserDetails } from "@renderer/hooks"; import { useUserDetails } from "@renderer/hooks";
const HERO_ANIMATION_THRESHOLD = 25; const HERO_ANIMATION_THRESHOLD = 25;
@ -36,9 +36,28 @@ export function GameDetailsContent() {
const { userDetails } = useUserDetails(); const { userDetails } = useUserDetails();
const { supportsCloudSync, setShowCloudSyncModal } = const { setShowCloudSyncModal, getGameBackupPreview } =
useContext(cloudSyncContext); useContext(cloudSyncContext);
const aboutTheGame = useMemo(() => {
const aboutTheGame = shopDetails?.about_the_game;
if (aboutTheGame) {
const document = new DOMParser().parseFromString(
aboutTheGame,
"text/html"
);
const $images = Array.from(document.querySelectorAll("img"));
$images.forEach(($image) => {
$image.loading = "lazy";
});
return document.body.outerHTML;
}
return t("no_shop_details");
}, [shopDetails, t]);
const [backdropOpactiy, setBackdropOpacity] = useState(1); const [backdropOpactiy, setBackdropOpacity] = useState(1);
const handleHeroLoad = async () => { const handleHeroLoad = async () => {
@ -87,6 +106,10 @@ export function GameDetailsContent() {
setShowCloudSyncModal(true); setShowCloudSyncModal(true);
}; };
useEffect(() => {
getGameBackupPreview();
}, [getGameBackupPreview]);
return ( return (
<div className={styles.wrapper({ blurredContent: hasNSFWContentBlocked })}> <div className={styles.wrapper({ blurredContent: hasNSFWContentBlocked })}>
<img <img
@ -121,32 +144,30 @@ export function GameDetailsContent() {
alt={game?.title} alt={game?.title}
/> />
{supportsCloudSync && ( <button
<button type="button"
type="button" className={styles.cloudSyncButton}
className={styles.cloudSyncButton} onClick={handleCloudSaveButtonClick}
onClick={handleCloudSaveButtonClick} >
<div
style={{
width: 16 + 4,
height: 16,
display: "flex",
alignItems: "center",
justifyContent: "center",
position: "relative",
}}
> >
<div <Lottie
style={{ animationData={cloudAnimation}
width: 16 + 4, loop
height: 16, autoplay
display: "flex", style={{ width: 26, position: "absolute", top: -3 }}
alignItems: "center", />
justifyContent: "center", </div>
position: "relative", {t("cloud_save")}
}} </button>
>
<Lottie
animationData={downloadingAnimation}
loop
autoplay
style={{ width: 26, position: "absolute", top: -3 }}
/>
</div>
{t("cloud_save")}
</button>
)}
</div> </div>
</div> </div>
</div> </div>
@ -160,7 +181,7 @@ export function GameDetailsContent() {
<div <div
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: shopDetails?.about_the_game ?? t("no_shop_details"), __html: aboutTheGame,
}} }}
className={styles.description} className={styles.description}
/> />

View file

@ -29,7 +29,7 @@ import { Downloader, getDownloadersForUri } from "@shared";
import { CloudSyncModal } from "./cloud-sync-modal/cloud-sync-modal"; import { CloudSyncModal } from "./cloud-sync-modal/cloud-sync-modal";
import { CloudSyncFilesModal } from "./cloud-sync-files-modal/cloud-sync-files-modal"; import { CloudSyncFilesModal } from "./cloud-sync-files-modal/cloud-sync-files-modal";
export function GameDetails() { export default function GameDetails() {
const [randomGame, setRandomGame] = useState<Steam250Game | null>(null); const [randomGame, setRandomGame] = useState<Steam250Game | null>(null);
const [randomizerLocked, setRandomizerLocked] = useState(false); const [randomizerLocked, setRandomizerLocked] = useState(false);

View file

@ -16,7 +16,7 @@ import Lottie, { type LottieRefCurrentProps } from "lottie-react";
import { buildGameDetailsPath } from "@renderer/helpers"; import { buildGameDetailsPath } from "@renderer/helpers";
import { CatalogueCategory } from "@shared"; import { CatalogueCategory } from "@shared";
export function Home() { export default function Home() {
const { t } = useTranslation("home"); const { t } = useTranslation("home");
const navigate = useNavigate(); const navigate = useNavigate();

View file

@ -17,7 +17,7 @@ import { buildGameDetailsPath } from "@renderer/helpers";
import { vars } from "@renderer/theme.css"; import { vars } from "@renderer/theme.css";
export function SearchResults() { export default function SearchResults() {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { t } = useTranslation("home"); const { t } = useTranslation("home");

View file

@ -1,7 +0,0 @@
export * from "./home/home";
export * from "./game-details/game-details";
export * from "./downloads/downloads";
export * from "./home/search-results";
export * from "./settings/settings";
export * from "./catalogue/catalogue";
export * from "./profile/profile";

View file

@ -3,28 +3,18 @@ import { globalStyle, style } from "@vanilla-extract/css";
export const profileAvatarEditContainer = style({ export const profileAvatarEditContainer = style({
alignSelf: "center", alignSelf: "center",
width: "128px", // width: "132px",
height: "128px", // height: "132px",
display: "flex", display: "flex",
borderRadius: "4px", // borderRadius: "4px",
color: vars.color.body, color: vars.color.body,
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
backgroundColor: vars.color.background, backgroundColor: vars.color.background,
position: "relative", position: "relative",
border: `solid 1px ${vars.color.border}`,
boxShadow: "0px 0px 5px 0px rgba(0, 0, 0, 0.7)",
cursor: "pointer", cursor: "pointer",
}); });
export const profileAvatar = style({
height: "100%",
width: "100%",
objectFit: "cover",
borderRadius: "4px",
overflow: "hidden",
});
export const profileAvatarEditOverlay = style({ export const profileAvatarEditOverlay = style({
position: "absolute", position: "absolute",
width: "100%", width: "100%",

View file

@ -2,8 +2,9 @@ import { useContext, useEffect } from "react";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { DeviceCameraIcon, PersonIcon } from "@primer/octicons-react"; import { DeviceCameraIcon } from "@primer/octicons-react";
import { import {
Avatar,
Button, Button,
Link, Link,
Modal, Modal,
@ -111,14 +112,14 @@ export function EditProfileModal(
if (filePaths && filePaths.length > 0) { if (filePaths && filePaths.length > 0) {
const path = filePaths[0]; const path = filePaths[0];
const { imagePath } = await window.electron // const { imagePath } = await window.electron
.processProfileImage(path) // .processProfileImage(path)
.catch(() => { // .catch(() => {
showErrorToast(t("image_process_failure")); // showErrorToast(t("image_process_failure"));
return { imagePath: null }; // return { imagePath: null };
}); // });
onChange(imagePath); onChange(path);
} }
}; };
@ -138,15 +139,11 @@ export function EditProfileModal(
className={styles.profileAvatarEditContainer} className={styles.profileAvatarEditContainer}
onClick={handleChangeProfileAvatar} onClick={handleChangeProfileAvatar}
> >
{imageUrl ? ( <Avatar
<img size={128}
className={styles.profileAvatar} src={imageUrl}
alt={userDetails?.displayName} alt={userDetails?.displayName}
src={imageUrl} />
/>
) : (
<PersonIcon size={96} />
)}
<div className={styles.profileAvatarEditOverlay}> <div className={styles.profileAvatarEditOverlay}>
<DeviceCameraIcon size={38} /> <DeviceCameraIcon size={38} />

View file

@ -4,8 +4,7 @@ import { useContext } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import * as styles from "./profile-content.css"; import * as styles from "./profile-content.css";
import { Link } from "@renderer/components"; import { Avatar, Link } from "@renderer/components";
import { PersonIcon } from "@primer/octicons-react";
export function FriendsBox() { export function FriendsBox() {
const { userProfile, userStats } = useContext(userProfileContext); const { userProfile, userStats } = useContext(userProfileContext);
@ -30,17 +29,11 @@ export function FriendsBox() {
{userProfile?.friends.map((friend) => ( {userProfile?.friends.map((friend) => (
<li key={friend.id}> <li key={friend.id}>
<Link to={`/profile/${friend.id}`} className={styles.listItem}> <Link to={`/profile/${friend.id}`} className={styles.listItem}>
{friend.profileImageUrl ? ( <Avatar
<img size={32}
src={friend.profileImageUrl!} src={friend.profileImageUrl}
alt={friend.displayName} alt={friend.displayName}
className={styles.listItemImage} />
/>
) : (
<div className={styles.defaultAvatarWrapper}>
<PersonIcon size={16} />
</div>
)}
<span className={styles.friendName}>{friend.displayName}</span> <span className={styles.friendName}>{friend.displayName}</span>
</Link> </Link>

View file

@ -1,17 +1,5 @@
import { SPACING_UNIT, vars } from "../../../theme.css"; import { SPACING_UNIT, vars } from "../../../theme.css";
import { keyframes, style } from "@vanilla-extract/css"; import { style } from "@vanilla-extract/css";
const animateBackground = keyframes({
"0%": {
backgroundPosition: "0% 50%",
},
"50%": {
backgroundPosition: "100% 50%",
},
"100%": {
backgroundPosition: "0% 50%",
},
});
export const profileContentBox = style({ export const profileContentBox = style({
display: "flex", display: "flex",
@ -74,7 +62,7 @@ export const heroPanel = style({
display: "flex", display: "flex",
gap: `${SPACING_UNIT}px`, gap: `${SPACING_UNIT}px`,
justifyContent: "space-between", justifyContent: "space-between",
backdropFilter: `blur(10px)`, backdropFilter: `blur(15px)`,
borderTop: `solid 1px rgba(255, 255, 255, 0.1)`, borderTop: `solid 1px rgba(255, 255, 255, 0.1)`,
boxShadow: "0px 0px 15px 0px rgba(0, 0, 0, 0.5)", boxShadow: "0px 0px 15px 0px rgba(0, 0, 0, 0.5)",
backgroundColor: "rgba(0, 0, 0, 0.3)", backgroundColor: "rgba(0, 0, 0, 0.3)",
@ -99,25 +87,3 @@ export const currentGameDetails = style({
gap: `${SPACING_UNIT}px`, gap: `${SPACING_UNIT}px`,
alignItems: "center", alignItems: "center",
}); });
export const xdTotal = style({
background: `linear-gradient(
60deg,
#f79533,
#f37055,
#ef4e7b,
#a166ab,
#5073b8,
#1098ad,
#07b39b,
#6fba82
)`,
width: "102px",
minWidth: "102px",
height: "102px",
animation: `${animateBackground} 4s ease alternate infinite`,
backgroundSize: "300% 300%",
zIndex: -1,
borderRadius: "4px",
position: "absolute",
});

View file

@ -8,13 +8,11 @@ import {
CheckCircleFillIcon, CheckCircleFillIcon,
PencilIcon, PencilIcon,
PersonAddIcon, PersonAddIcon,
PersonIcon,
SignOutIcon, SignOutIcon,
UploadIcon,
XCircleFillIcon, XCircleFillIcon,
} from "@primer/octicons-react"; } from "@primer/octicons-react";
import { buildGameDetailsPath } from "@renderer/helpers"; import { buildGameDetailsPath } from "@renderer/helpers";
import { Button, Link } from "@renderer/components"; import { Avatar, Button, Link } from "@renderer/components";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
useAppSelector, useAppSelector,
@ -28,16 +26,21 @@ import { useNavigate } from "react-router-dom";
import type { FriendRequestAction } from "@types"; import type { FriendRequestAction } from "@types";
import { EditProfileModal } from "../edit-profile-modal/edit-profile-modal"; import { EditProfileModal } from "../edit-profile-modal/edit-profile-modal";
import Skeleton from "react-loading-skeleton"; import Skeleton from "react-loading-skeleton";
import { UploadBackgroundImageButton } from "../upload-background-image-button/upload-background-image-button";
type FriendAction = type FriendAction =
| FriendRequestAction | FriendRequestAction
| ("BLOCK" | "UNDO_FRIENDSHIP" | "SEND"); | ("BLOCK" | "UNDO_FRIENDSHIP" | "SEND");
const backgroundImageLayer =
"linear-gradient(135deg, rgb(0 0 0 / 50%), rgb(0 0 0 / 60%))";
export function ProfileHero() { export function ProfileHero() {
const [showEditProfileModal, setShowEditProfileModal] = useState(false); const [showEditProfileModal, setShowEditProfileModal] = useState(false);
const [isPerformingAction, setIsPerformingAction] = useState(false); const [isPerformingAction, setIsPerformingAction] = useState(false);
const { isMe, getUserProfile, userProfile } = useContext(userProfileContext); const { isMe, getUserProfile, userProfile, heroBackground, backgroundImage } =
useContext(userProfileContext);
const { const {
signOut, signOut,
updateFriendRequestState, updateFriendRequestState,
@ -48,8 +51,6 @@ export function ProfileHero() {
const { gameRunning } = useAppSelector((state) => state.gameRunning); const { gameRunning } = useAppSelector((state) => state.gameRunning);
const [hero, setHero] = useState("");
const { t } = useTranslation("user_profile"); const { t } = useTranslation("user_profile");
const { formatDistance } = useDate(); const { formatDistance } = useDate();
@ -186,6 +187,7 @@ export function ProfileHero() {
handleFriendAction(userProfile.id, "UNDO_FRIENDSHIP") handleFriendAction(userProfile.id, "UNDO_FRIENDSHIP")
} }
disabled={isPerformingAction} disabled={isPerformingAction}
style={{ borderColor: vars.color.body }}
> >
<XCircleFillIcon /> <XCircleFillIcon />
{t("undo_friendship")} {t("undo_friendship")}
@ -260,35 +262,6 @@ export function ProfileHero() {
return userProfile?.currentGame; return userProfile?.currentGame;
}, [isMe, userProfile, gameRunning]); }, [isMe, userProfile, gameRunning]);
const handleChangeCoverClick = async () => {
const { filePaths } = await window.electron.showOpenDialog({
properties: ["openFile"],
filters: [
{
name: "Image",
extensions: ["jpg", "jpeg", "png", "gif", "webp"],
},
],
});
if (filePaths && filePaths.length > 0) {
const path = filePaths[0];
setHero(path);
// onChange(imagePath);
}
};
const getImageUrl = () => {
if (hero) return `local:${hero}`;
// if (userDetails?.profileImageUrl) return userDetails.profileImageUrl;
return "";
};
// const imageUrl = getImageUrl();
return ( return (
<> <>
{/* <ConfirmationModal {/* <ConfirmationModal
@ -304,21 +277,26 @@ export function ProfileHero() {
onClose={() => setShowEditProfileModal(false)} onClose={() => setShowEditProfileModal(false)}
/> />
<section className={styles.profileContentBox}> <section
<img className={styles.profileContentBox}
src={getImageUrl()} style={{ background: heroBackground }}
alt="" >
style={{ {backgroundImage && (
position: "absolute", <img
width: "100%", src={backgroundImage}
height: "100%", alt=""
objectFit: "cover", style={{
}} position: "absolute",
/> width: "100%",
height: "100%",
objectFit: "cover",
}}
/>
)}
<div <div
style={{ style={{
background: background: backgroundImage ? backgroundImageLayer : "transparent",
"linear-gradient(135deg, rgb(0 0 0 / 70%), rgb(0 0 0 / 60%))",
width: "100%", width: "100%",
height: "100%", height: "100%",
zIndex: 1, zIndex: 1,
@ -330,16 +308,11 @@ export function ProfileHero() {
className={styles.profileAvatarButton} className={styles.profileAvatarButton}
onClick={handleAvatarClick} onClick={handleAvatarClick}
> >
<div className={styles.xdTotal} /> <Avatar
{userProfile?.profileImageUrl ? ( size={96}
<img alt={userProfile?.displayName}
className={styles.profileAvatar} src={userProfile?.profileImageUrl}
alt={userProfile?.displayName} />
src={userProfile?.profileImageUrl}
/>
) : (
<PersonIcon size={72} />
)}
</button> </button>
<div className={styles.profileInformation}> <div className={styles.profileInformation}>
@ -379,28 +352,14 @@ export function ProfileHero() {
)} )}
</div> </div>
<Button <UploadBackgroundImageButton />
theme="outline"
style={{
position: "absolute",
top: 16,
right: 16,
borderColor: vars.color.body,
}}
onClick={handleChangeCoverClick}
>
<UploadIcon />
Upload cover
</Button>
</div> </div>
</div> </div>
<div <div
className={styles.heroPanel} className={styles.heroPanel}
// style={{ background: heroBackground }}
style={{ style={{
background: background: backgroundImage ? backgroundImageLayer : heroBackground,
"linear-gradient(135deg, rgb(0 0 0 / 70%), rgb(0 0 0 / 60%))",
}} }}
> >
<div <div

View file

@ -6,7 +6,7 @@ import * as styles from "./profile.css";
import { UserProfileContextProvider } from "@renderer/context"; import { UserProfileContextProvider } from "@renderer/context";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
export function Profile() { export default function Profile() {
const { userId } = useParams(); const { userId } = useParams();
return ( return (

View file

@ -0,0 +1,12 @@
import { style } from "@vanilla-extract/css";
import { vars } from "../../../theme.css";
export const uploadBackgroundImageButton = style({
position: "absolute",
top: 16,
right: 16,
borderColor: vars.color.body,
boxShadow: "0px 0px 10px 0px rgba(0, 0, 0, 0.8)",
backgroundColor: "rgba(0, 0, 0, 0.1)",
backdropFilter: "blur(20px)",
});

View file

@ -0,0 +1,58 @@
import { UploadIcon } from "@primer/octicons-react";
import { Button } from "@renderer/components";
import { useContext, useState } from "react";
import { userProfileContext } from "@renderer/context";
import * as styles from "./upload-background-image-button.css";
import { useToast, useUserDetails } from "@renderer/hooks";
export function UploadBackgroundImageButton() {
const [isUploadingBackgroundImage, setIsUploadingBackgorundImage] =
useState(false);
const { isMe, setSelectedBackgroundImage } = useContext(userProfileContext);
const { patchUser } = useUserDetails();
const { showSuccessToast } = useToast();
const handleChangeCoverClick = async () => {
try {
const { filePaths } = await window.electron.showOpenDialog({
properties: ["openFile"],
filters: [
{
name: "Image",
extensions: ["jpg", "jpeg", "png", "gif", "webp"],
},
],
});
if (filePaths && filePaths.length > 0) {
const path = filePaths[0];
setSelectedBackgroundImage(path);
setIsUploadingBackgorundImage(true);
await patchUser({ backgroundImageUrl: path });
showSuccessToast("Background image updated");
}
} finally {
setIsUploadingBackgorundImage(false);
}
};
if (!isMe) return null;
return (
<Button
theme="outline"
className={styles.uploadBackgroundImageButton}
onClick={handleChangeCoverClick}
disabled={isUploadingBackgroundImage}
>
<UploadIcon />
{isUploadingBackgroundImage ? "Uploading..." : "Upload background"}
</Button>
);
}

View file

@ -15,7 +15,7 @@ import { SettingsPrivacy } from "./settings-privacy";
import { useUserDetails } from "@renderer/hooks"; import { useUserDetails } from "@renderer/hooks";
import { useMemo } from "react"; import { useMemo } from "react";
export function Settings() { export default function Settings() {
const { t } = useTranslation("settings"); const { t } = useTranslation("settings");
const { userDetails } = useUserDetails(); const { userDetails } = useUserDetails();

View file

@ -1,11 +1,8 @@
import { import { CheckCircleIcon, XCircleIcon } from "@primer/octicons-react";
CheckCircleIcon,
PersonIcon,
XCircleIcon,
} from "@primer/octicons-react";
import * as styles from "./user-friend-modal.css"; import * as styles from "./user-friend-modal.css";
import { SPACING_UNIT } from "@renderer/theme.css"; import { SPACING_UNIT } from "@renderer/theme.css";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Avatar } from "@renderer/components";
export type UserFriendItemProps = { export type UserFriendItemProps = {
userId: string; userId: string;
@ -109,17 +106,8 @@ export const UserFriendItem = (props: UserFriendItemProps) => {
return ( return (
<div className={styles.friendListContainer}> <div className={styles.friendListContainer}>
<div className={styles.friendListButton} style={{ cursor: "inherit" }}> <div className={styles.friendListButton} style={{ cursor: "inherit" }}>
<div className={styles.friendAvatarContainer}> <Avatar size={35} src={profileImageUrl} alt={displayName} />
{profileImageUrl ? (
<img
className={styles.profileAvatar}
alt={displayName}
src={profileImageUrl}
/>
) : (
<PersonIcon size={24} />
)}
</div>
<div <div
style={{ style={{
display: "flex", display: "flex",
@ -154,17 +142,7 @@ export const UserFriendItem = (props: UserFriendItemProps) => {
className={styles.friendListButton} className={styles.friendListButton}
onClick={() => props.onClickItem(userId)} onClick={() => props.onClickItem(userId)}
> >
<div className={styles.friendAvatarContainer}> <Avatar size={35} src={profileImageUrl} alt={displayName} />
{profileImageUrl ? (
<img
className={styles.profileAvatar}
alt={displayName}
src={profileImageUrl}
/>
) : (
<PersonIcon size={24} />
)}
</div>
<div <div
style={{ style={{
display: "flex", display: "flex",

View file

@ -1,20 +1,6 @@
import { SPACING_UNIT, vars } from "../../../theme.css"; import { SPACING_UNIT, vars } from "../../../theme.css";
import { style } from "@vanilla-extract/css"; import { style } from "@vanilla-extract/css";
export const friendAvatarContainer = style({
width: "35px",
minWidth: "35px",
height: "35px",
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)",
});
export const friendListDisplayName = style({ export const friendListDisplayName = style({
fontWeight: "bold", fontWeight: "bold",
fontSize: vars.size.body, fontSize: vars.size.body,
@ -24,12 +10,6 @@ export const friendListDisplayName = style({
whiteSpace: "nowrap", whiteSpace: "nowrap",
}); });
export const profileAvatar = style({
height: "100%",
width: "100%",
objectFit: "cover",
});
export const friendListContainer = style({ export const friendListContainer = style({
display: "flex", display: "flex",
gap: `${SPACING_UNIT * 3}px`, gap: `${SPACING_UNIT * 3}px`,

View file

@ -205,6 +205,7 @@ export interface UserDetails {
username: string; username: string;
displayName: string; displayName: string;
profileImageUrl: string | null; profileImageUrl: string | null;
backgroundImageUrl: string | null;
profileVisibility: ProfileVisibility; profileVisibility: ProfileVisibility;
bio: string; bio: string;
} }
@ -213,6 +214,7 @@ export interface UserProfile {
id: string; id: string;
displayName: string; displayName: string;
profileImageUrl: string | null; profileImageUrl: string | null;
backgroundImageUrl: string | null;
profileVisibility: ProfileVisibility; profileVisibility: ProfileVisibility;
libraryGames: UserGame[]; libraryGames: UserGame[];
recentGames: UserGame[]; recentGames: UserGame[];
@ -227,6 +229,7 @@ export interface UpdateProfileRequest {
displayName?: string; displayName?: string;
profileVisibility?: ProfileVisibility; profileVisibility?: ProfileVisibility;
profileImageUrl?: string | null; profileImageUrl?: string | null;
backgroundImageUrl?: string | null;
bio?: string; bio?: string;
} }