mirror of
https://github.com/hydralauncher/hydra.git
synced 2025-02-15 04:32:13 +00:00
feat: enabling gif upload
This commit is contained in:
parent
05653500b6
commit
05625e7594
36 changed files with 403 additions and 373 deletions
|
@ -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);
|
|
|
@ -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";
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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) => {
|
||||||
|
|
23
src/renderer/src/components/avatar/avatar.css.ts
Normal file
23
src/renderer/src/components/avatar/avatar.css.ts
Normal 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",
|
||||||
|
});
|
32
src/renderer/src/components/avatar/avatar.tsx
Normal file
32
src/renderer/src/components/avatar/avatar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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}>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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";
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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}>
|
||||||
|
|
|
@ -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} />
|
||||||
|
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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}
|
||||||
|
|
|
@ -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,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
4
src/renderer/src/declaration.d.ts
vendored
4
src/renderer/src/declaration.d.ts
vendored
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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");
|
||||||
|
|
|
@ -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");
|
||||||
|
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
@ -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");
|
||||||
|
|
|
@ -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";
|
|
|
@ -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%",
|
||||||
|
|
|
@ -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} />
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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",
|
|
||||||
});
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 (
|
||||||
|
|
|
@ -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)",
|
||||||
|
});
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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();
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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`,
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue