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-backup-preview";
import "./cloud-save/upload-save-game";
import "./cloud-save/check-game-cloud-sync-support";
import "./cloud-save/delete-game-artifact";
import "./notifications/publish-new-repacks-notification";
import { isPortableVersion } from "@main/helpers";

View File

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

View File

@ -160,8 +160,6 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("getGameArtifacts", objectId, shop),
getGameBackupPreview: (objectId: string, shop: GameShop) =>
ipcRenderer.invoke("getGameBackupPreview", objectId, shop),
checkGameCloudSyncSupport: (objectId: string, shop: GameShop) =>
ipcRenderer.invoke("checkGameCloudSyncSupport", objectId, shop),
deleteGameArtifact: (gameArtifactId: string) =>
ipcRenderer.invoke("deleteGameArtifact", gameArtifactId),
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}
>
<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.titleContainer}>

View File

@ -49,7 +49,12 @@ export function Hero() {
<div className={styles.content}>
{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>
</div>

View File

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

View File

@ -31,19 +31,6 @@ export const profileButtonContent = style({
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({
display: "flex",
flexDirection: "column",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -11,7 +11,7 @@ import { LibraryGame } from "@types";
import { orderBy } from "lodash-es";
import { ArrowDownIcon } from "@primer/octicons-react";
export function Downloads() {
export default function Downloads() {
const { library, updateLibrary } = useLibrary();
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 Color from "color";
@ -13,7 +13,7 @@ import { cloudSyncContext, gameDetailsContext } from "@renderer/context";
import { steamUrlBuilder } from "@shared";
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";
const HERO_ANIMATION_THRESHOLD = 25;
@ -36,9 +36,28 @@ export function GameDetailsContent() {
const { userDetails } = useUserDetails();
const { supportsCloudSync, setShowCloudSyncModal } =
const { setShowCloudSyncModal, getGameBackupPreview } =
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 handleHeroLoad = async () => {
@ -87,6 +106,10 @@ export function GameDetailsContent() {
setShowCloudSyncModal(true);
};
useEffect(() => {
getGameBackupPreview();
}, [getGameBackupPreview]);
return (
<div className={styles.wrapper({ blurredContent: hasNSFWContentBlocked })}>
<img
@ -121,32 +144,30 @@ export function GameDetailsContent() {
alt={game?.title}
/>
{supportsCloudSync && (
<button
type="button"
className={styles.cloudSyncButton}
onClick={handleCloudSaveButtonClick}
<button
type="button"
className={styles.cloudSyncButton}
onClick={handleCloudSaveButtonClick}
>
<div
style={{
width: 16 + 4,
height: 16,
display: "flex",
alignItems: "center",
justifyContent: "center",
position: "relative",
}}
>
<div
style={{
width: 16 + 4,
height: 16,
display: "flex",
alignItems: "center",
justifyContent: "center",
position: "relative",
}}
>
<Lottie
animationData={downloadingAnimation}
loop
autoplay
style={{ width: 26, position: "absolute", top: -3 }}
/>
</div>
{t("cloud_save")}
</button>
)}
<Lottie
animationData={cloudAnimation}
loop
autoplay
style={{ width: 26, position: "absolute", top: -3 }}
/>
</div>
{t("cloud_save")}
</button>
</div>
</div>
</div>
@ -160,7 +181,7 @@ export function GameDetailsContent() {
<div
dangerouslySetInnerHTML={{
__html: shopDetails?.about_the_game ?? t("no_shop_details"),
__html: aboutTheGame,
}}
className={styles.description}
/>

View File

@ -29,7 +29,7 @@ import { Downloader, getDownloadersForUri } from "@shared";
import { CloudSyncModal } from "./cloud-sync-modal/cloud-sync-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 [randomizerLocked, setRandomizerLocked] = useState(false);

View File

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

View File

@ -17,7 +17,7 @@ import { buildGameDetailsPath } from "@renderer/helpers";
import { vars } from "@renderer/theme.css";
export function SearchResults() {
export default function SearchResults() {
const dispatch = useAppDispatch();
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({
alignSelf: "center",
width: "128px",
height: "128px",
// width: "132px",
// height: "132px",
display: "flex",
borderRadius: "4px",
// borderRadius: "4px",
color: vars.color.body,
justifyContent: "center",
alignItems: "center",
backgroundColor: vars.color.background,
position: "relative",
border: `solid 1px ${vars.color.border}`,
boxShadow: "0px 0px 5px 0px rgba(0, 0, 0, 0.7)",
cursor: "pointer",
});
export const profileAvatar = style({
height: "100%",
width: "100%",
objectFit: "cover",
borderRadius: "4px",
overflow: "hidden",
});
export const profileAvatarEditOverlay = style({
position: "absolute",
width: "100%",

View File

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

View File

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

View File

@ -1,17 +1,5 @@
import { SPACING_UNIT, vars } from "../../../theme.css";
import { keyframes, style } from "@vanilla-extract/css";
const animateBackground = keyframes({
"0%": {
backgroundPosition: "0% 50%",
},
"50%": {
backgroundPosition: "100% 50%",
},
"100%": {
backgroundPosition: "0% 50%",
},
});
import { style } from "@vanilla-extract/css";
export const profileContentBox = style({
display: "flex",
@ -74,7 +62,7 @@ export const heroPanel = style({
display: "flex",
gap: `${SPACING_UNIT}px`,
justifyContent: "space-between",
backdropFilter: `blur(10px)`,
backdropFilter: `blur(15px)`,
borderTop: `solid 1px rgba(255, 255, 255, 0.1)`,
boxShadow: "0px 0px 15px 0px rgba(0, 0, 0, 0.5)",
backgroundColor: "rgba(0, 0, 0, 0.3)",
@ -99,25 +87,3 @@ export const currentGameDetails = style({
gap: `${SPACING_UNIT}px`,
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,
PencilIcon,
PersonAddIcon,
PersonIcon,
SignOutIcon,
UploadIcon,
XCircleFillIcon,
} from "@primer/octicons-react";
import { buildGameDetailsPath } from "@renderer/helpers";
import { Button, Link } from "@renderer/components";
import { Avatar, Button, Link } from "@renderer/components";
import { useTranslation } from "react-i18next";
import {
useAppSelector,
@ -28,16 +26,21 @@ import { useNavigate } from "react-router-dom";
import type { FriendRequestAction } from "@types";
import { EditProfileModal } from "../edit-profile-modal/edit-profile-modal";
import Skeleton from "react-loading-skeleton";
import { UploadBackgroundImageButton } from "../upload-background-image-button/upload-background-image-button";
type FriendAction =
| FriendRequestAction
| ("BLOCK" | "UNDO_FRIENDSHIP" | "SEND");
const backgroundImageLayer =
"linear-gradient(135deg, rgb(0 0 0 / 50%), rgb(0 0 0 / 60%))";
export function ProfileHero() {
const [showEditProfileModal, setShowEditProfileModal] = useState(false);
const [isPerformingAction, setIsPerformingAction] = useState(false);
const { isMe, getUserProfile, userProfile } = useContext(userProfileContext);
const { isMe, getUserProfile, userProfile, heroBackground, backgroundImage } =
useContext(userProfileContext);
const {
signOut,
updateFriendRequestState,
@ -48,8 +51,6 @@ export function ProfileHero() {
const { gameRunning } = useAppSelector((state) => state.gameRunning);
const [hero, setHero] = useState("");
const { t } = useTranslation("user_profile");
const { formatDistance } = useDate();
@ -186,6 +187,7 @@ export function ProfileHero() {
handleFriendAction(userProfile.id, "UNDO_FRIENDSHIP")
}
disabled={isPerformingAction}
style={{ borderColor: vars.color.body }}
>
<XCircleFillIcon />
{t("undo_friendship")}
@ -260,35 +262,6 @@ export function ProfileHero() {
return userProfile?.currentGame;
}, [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 (
<>
{/* <ConfirmationModal
@ -304,21 +277,26 @@ export function ProfileHero() {
onClose={() => setShowEditProfileModal(false)}
/>
<section className={styles.profileContentBox}>
<img
src={getImageUrl()}
alt=""
style={{
position: "absolute",
width: "100%",
height: "100%",
objectFit: "cover",
}}
/>
<section
className={styles.profileContentBox}
style={{ background: heroBackground }}
>
{backgroundImage && (
<img
src={backgroundImage}
alt=""
style={{
position: "absolute",
width: "100%",
height: "100%",
objectFit: "cover",
}}
/>
)}
<div
style={{
background:
"linear-gradient(135deg, rgb(0 0 0 / 70%), rgb(0 0 0 / 60%))",
background: backgroundImage ? backgroundImageLayer : "transparent",
width: "100%",
height: "100%",
zIndex: 1,
@ -330,16 +308,11 @@ export function ProfileHero() {
className={styles.profileAvatarButton}
onClick={handleAvatarClick}
>
<div className={styles.xdTotal} />
{userProfile?.profileImageUrl ? (
<img
className={styles.profileAvatar}
alt={userProfile?.displayName}
src={userProfile?.profileImageUrl}
/>
) : (
<PersonIcon size={72} />
)}
<Avatar
size={96}
alt={userProfile?.displayName}
src={userProfile?.profileImageUrl}
/>
</button>
<div className={styles.profileInformation}>
@ -379,28 +352,14 @@ export function ProfileHero() {
)}
</div>
<Button
theme="outline"
style={{
position: "absolute",
top: 16,
right: 16,
borderColor: vars.color.body,
}}
onClick={handleChangeCoverClick}
>
<UploadIcon />
Upload cover
</Button>
<UploadBackgroundImageButton />
</div>
</div>
<div
className={styles.heroPanel}
// style={{ background: heroBackground }}
style={{
background:
"linear-gradient(135deg, rgb(0 0 0 / 70%), rgb(0 0 0 / 60%))",
background: backgroundImage ? backgroundImageLayer : heroBackground,
}}
>
<div

View File

@ -6,7 +6,7 @@ import * as styles from "./profile.css";
import { UserProfileContextProvider } from "@renderer/context";
import { useParams } from "react-router-dom";
export function Profile() {
export default function Profile() {
const { userId } = useParams();
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 { useMemo } from "react";
export function Settings() {
export default function Settings() {
const { t } = useTranslation("settings");
const { userDetails } = useUserDetails();

View File

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

View File

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

View File

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