Merge branch 'rc/v2.0' into feat/show-toast-after-create-shortcut

This commit is contained in:
Zamitto 2024-06-22 01:59:42 -03:00
commit dcdc6a7114
93 changed files with 2338 additions and 604 deletions

View file

@ -6,7 +6,7 @@
<title>Hydra</title>
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://steamcdn-a.akamaihd.net https://shared.akamai.steamstatic.com https://cdn.cloudflare.steamstatic.com https://cdn2.steamgriddb.com https://cdn.akamai.steamstatic.com; media-src 'self' data: https://steamcdn-a.akamaihd.net https://cdn.cloudflare.steamstatic.com https://cdn2.steamgriddb.com https://cdn.akamai.steamstatic.com https://shared.akamai.steamstatic.com;"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: local: https://*.s3.amazonaws.com https://steamcdn-a.akamaihd.net https://shared.akamai.steamstatic.com https://cdn.cloudflare.steamstatic.com https://cdn2.steamgriddb.com https://cdn.akamai.steamstatic.com; media-src 'self' local: data: https://steamcdn-a.akamaihd.net https://cdn.cloudflare.steamstatic.com https://cdn2.steamgriddb.com https://cdn.akamai.steamstatic.com https://shared.akamai.steamstatic.com;"
/>
</head>
<body style="background-color: #1c1c1c">

View file

@ -111,6 +111,6 @@ export const titleBar = style({
alignItems: "center",
padding: `0 ${SPACING_UNIT * 2}px`,
WebkitAppRegion: "drag",
zIndex: "2",
zIndex: "4",
borderBottom: `1px solid ${vars.color.border}`,
} as ComplexStyleRule);

View file

@ -7,6 +7,8 @@ import {
useAppSelector,
useDownload,
useLibrary,
useToast,
useUserDetails,
} from "@renderer/hooks";
import * as styles from "./app.css";
@ -18,7 +20,11 @@ import {
setUserPreferences,
toggleDraggingDisabled,
closeToast,
setUserDetails,
setProfileBackground,
setGameRunning,
} from "@renderer/features";
import { useTranslation } from "react-i18next";
export interface AppProps {
children: React.ReactNode;
@ -26,21 +32,30 @@ export interface AppProps {
export function App() {
const contentRef = useRef<HTMLDivElement>(null);
const { updateLibrary } = useLibrary();
const { updateLibrary, library } = useLibrary();
const { t } = useTranslation("app");
const { clearDownload, setLastPacket } = useDownload();
const { fetchUserDetails, updateUserDetails, clearUserDetails } =
useUserDetails();
const dispatch = useAppDispatch();
const navigate = useNavigate();
const location = useLocation();
const search = useAppSelector((state) => state.search.value);
const draggingDisabled = useAppSelector(
(state) => state.window.draggingDisabled
);
const toast = useAppSelector((state) => state.toast);
const { showSuccessToast } = useToast();
useEffect(() => {
Promise.all([window.electron.getUserPreferences(), updateLibrary()]).then(
([preferences]) => {
@ -67,6 +82,75 @@ export function App() {
};
}, [clearDownload, setLastPacket, updateLibrary]);
useEffect(() => {
const cachedUserDetails = window.localStorage.getItem("userDetails");
if (cachedUserDetails) {
const { profileBackground, ...userDetails } =
JSON.parse(cachedUserDetails);
dispatch(setUserDetails(userDetails));
dispatch(setProfileBackground(profileBackground));
}
window.electron.isUserLoggedIn().then((isLoggedIn) => {
if (isLoggedIn) {
fetchUserDetails().then((response) => {
if (response) updateUserDetails(response);
});
}
});
}, [fetchUserDetails, updateUserDetails, dispatch]);
const onSignIn = useCallback(() => {
fetchUserDetails().then((response) => {
if (response) {
updateUserDetails(response);
showSuccessToast(t("successfully_signed_in"));
}
});
}, [fetchUserDetails, t, showSuccessToast, updateUserDetails]);
useEffect(() => {
const unsubscribe = window.electron.onGamesRunning((gamesRunning) => {
if (gamesRunning.length) {
const lastGame = gamesRunning[gamesRunning.length - 1];
const libraryGame = library.find(
(library) => library.id === lastGame.id
);
if (libraryGame) {
dispatch(
setGameRunning({
...libraryGame,
sessionDurationInMillis: lastGame.sessionDurationInMillis,
})
);
return;
}
}
dispatch(setGameRunning(null));
});
return () => {
unsubscribe();
};
}, [dispatch, library]);
useEffect(() => {
const listeners = [
window.electron.onSignIn(onSignIn),
window.electron.onLibraryBatchComplete(() => {
updateLibrary();
}),
window.electron.onSignOut(() => clearUserDetails()),
];
return () => {
listeners.forEach((unsubscribe) => unsubscribe());
};
}, [onSignIn, updateLibrary, clearUserDetails]);
const handleSearch = useCallback(
(query: string) => {
dispatch(setSearch(query));
@ -119,6 +203,13 @@ export function App() {
</div>
)}
<Toast
visible={toast.visible}
message={toast.message}
type={toast.type}
onClose={handleToastClose}
/>
<main>
<Sidebar />
@ -136,13 +227,6 @@ export function App() {
</main>
<BottomPanel />
<Toast
visible={toast.visible}
message={toast.message}
type={toast.type}
onClose={handleToastClose}
/>
</>
);
}

View file

@ -1,7 +1,7 @@
import { keyframes } from "@vanilla-extract/css";
import { recipe } from "@vanilla-extract/recipes";
import { SPACING_UNIT } from "../../theme.css";
import { SPACING_UNIT, vars } from "../../theme.css";
export const backdropFadeIn = keyframes({
"0%": { backdropFilter: "blur(0px)", backgroundColor: "rgba(0, 0, 0, 0.5)" },
@ -30,8 +30,8 @@ export const backdrop = recipe({
display: "flex",
justifyContent: "center",
alignItems: "center",
zIndex: 1,
top: 0,
zIndex: vars.zIndex.backdrop,
top: "0",
padding: `${SPACING_UNIT * 3}px`,
backdropFilter: "blur(2px)",
transition: "all ease 0.2s",

View file

@ -12,7 +12,7 @@ export const bottomPanel = style({
transition: "all ease 0.2s",
justifyContent: "space-between",
position: "relative",
zIndex: "1",
zIndex: vars.zIndex.bottomPanel,
});
export const downloadsButton = style({

View file

@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useDownload } from "@renderer/hooks";
import { useDownload, useUserDetails } from "@renderer/hooks";
import * as styles from "./bottom-panel.css";
@ -13,16 +13,23 @@ export function BottomPanel() {
const navigate = useNavigate();
const { userDetails } = useUserDetails();
const { lastPacket, progress, downloadSpeed, eta } = useDownload();
const isGameDownloading = !!lastPacket?.game;
const [version, setVersion] = useState("");
const [sessionHash, setSessionHash] = useState<null | string>("");
useEffect(() => {
window.electron.getVersion().then((result) => setVersion(result));
}, []);
useEffect(() => {
window.electron.getSessionHash().then((result) => setSessionHash(result));
}, [userDetails?.id]);
const status = useMemo(() => {
if (isGameDownloading) {
if (lastPacket?.isDownloadingMetadata)
@ -65,7 +72,8 @@ export function BottomPanel() {
</button>
<small>
v{version} &quot;{VERSION_CODENAME}&quot;
{sessionHash ? `${sessionHash} -` : ""} v{version} &quot;
{VERSION_CODENAME}&quot;
</small>
</footer>
);

View file

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

View file

@ -9,8 +9,8 @@ import {
} from "@renderer/helpers";
import { useTranslation } from "react-i18next";
const FEATURED_GAME_TITLE = "Horizon Forbidden West™ Complete Edition";
const FEATURED_GAME_ID = "2420110";
const FEATURED_GAME_TITLE = "Ghost of Tsushima DIRECTOR'S CUT";
const FEATURED_GAME_ID = "2215430";
export function Hero() {
const [featuredGameDetails, setFeaturedGameDetails] =

View file

@ -0,0 +1,66 @@
import { style } from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "../../theme.css";
export const profileButton = style({
display: "flex",
cursor: "pointer",
transition: "all ease 0.1s",
padding: `${SPACING_UNIT * 2}px ${SPACING_UNIT * 2}px`,
color: vars.color.muted,
borderBottom: `solid 1px ${vars.color.border}`,
boxShadow: "0px 0px 15px 0px rgb(0 0 0 / 70%)",
":hover": {
backgroundColor: "rgba(255, 255, 255, 0.15)",
},
});
export const profileButtonContent = style({
display: "flex",
alignItems: "center",
gap: `${SPACING_UNIT + SPACING_UNIT / 2}px`,
height: "40px",
width: "100%",
});
export const profileAvatar = style({
width: "35px",
height: "35px",
borderRadius: "50%",
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",
alignItems: "flex-start",
flex: "1",
minWidth: 0,
});
export const statusBadge = style({
width: "9px",
height: "9px",
borderRadius: "50%",
backgroundColor: vars.color.danger,
position: "absolute",
bottom: "-2px",
right: "-3px",
zIndex: "1",
});
export const profileButtonTitle = style({
fontWeight: "bold",
fontSize: vars.size.body,
width: "100%",
textAlign: "left",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
});

View file

@ -0,0 +1,75 @@
import { useNavigate } from "react-router-dom";
import { PersonIcon } from "@primer/octicons-react";
import * as styles from "./sidebar-profile.css";
import { useAppSelector, useUserDetails } from "@renderer/hooks";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
export function SidebarProfile() {
const navigate = useNavigate();
const { t } = useTranslation("sidebar");
const { userDetails, profileBackground } = useUserDetails();
const { gameRunning } = useAppSelector((state) => state.gameRunning);
const handleButtonClick = () => {
if (userDetails === null) {
window.electron.openAuthWindow();
return;
}
navigate(`/user/${userDetails!.id}`);
};
const profileButtonBackground = useMemo(() => {
if (profileBackground) return profileBackground;
return undefined;
}, [profileBackground]);
return (
<button
type="button"
className={styles.profileButton}
style={{ background: profileButtonBackground }}
onClick={handleButtonClick}
>
<div className={styles.profileButtonContent}>
<div className={styles.profileAvatar}>
{userDetails?.profileImageUrl ? (
<img
className={styles.profileAvatar}
src={userDetails.profileImageUrl}
alt={userDetails.displayName}
/>
) : (
<PersonIcon />
)}
</div>
<div className={styles.profileButtonInformation}>
<p className={styles.profileButtonTitle}>
{userDetails ? userDetails.displayName : t("sign_in")}
</p>
{userDetails && gameRunning && (
<div>
<small>{gameRunning.title}</small>
</div>
)}
</div>
{userDetails && gameRunning && (
<img
alt={gameRunning.title}
width={24}
style={{ borderRadius: 4 }}
src={gameRunning.iconUrl}
/>
)}
</div>
</button>
);
}

View file

@ -125,46 +125,3 @@ export const section = style({
flexDirection: "column",
paddingBottom: `${SPACING_UNIT}px`,
});
export const profileButton = style({
display: "flex",
cursor: "pointer",
transition: "all ease 0.1s",
gap: `${SPACING_UNIT + SPACING_UNIT / 2}px`,
alignItems: "center",
padding: `${SPACING_UNIT * 2}px ${SPACING_UNIT * 2}px`,
color: vars.color.muted,
borderBottom: `solid 1px ${vars.color.border}`,
boxShadow: "0px 0px 15px 0px #000000",
":hover": {
backgroundColor: "rgba(255, 255, 255, 0.15)",
},
});
export const profileAvatar = style({
width: "30px",
height: "30px",
borderRadius: "50%",
display: "flex",
justifyContent: "center",
alignItems: "center",
backgroundColor: vars.color.background,
position: "relative",
});
export const profileButtonInformation = style({
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
});
export const statusBadge = style({
width: "9px",
height: "9px",
borderRadius: "50%",
backgroundColor: vars.color.danger,
position: "absolute",
bottom: "-2px",
right: "-3px",
zIndex: "1",
});

View file

@ -13,7 +13,7 @@ import * as styles from "./sidebar.css";
import { buildGameDetailsPath } from "@renderer/helpers";
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
import { PersonIcon } from "@primer/octicons-react";
import { SidebarProfile } from "./sidebar-profile";
const SIDEBAR_MIN_WIDTH = 200;
const SIDEBAR_INITIAL_WIDTH = 250;
@ -154,18 +154,7 @@ export function Sidebar() {
maxWidth: sidebarWidth,
}}
>
<button type="button" className={styles.profileButton}>
<div className={styles.profileAvatar}>
<PersonIcon />
<div className={styles.statusBadge} />
</div>
<div className={styles.profileButtonInformation}>
<p style={{ fontWeight: "bold" }}>hydra</p>
<p style={{ fontSize: 12 }}>Jogando ABC</p>
</div>
</button>
<SidebarProfile />
<div
className={styles.content({

View file

@ -31,7 +31,7 @@ export const toast = recipe({
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
zIndex: "0",
zIndex: vars.zIndex.toast,
maxWidth: "500px",
},
variants: {

View file

@ -1,6 +1,6 @@
import { Downloader } from "@shared";
export const VERSION_CODENAME = "Exodus";
export const VERSION_CODENAME = "Leviticus";
export const DOWNLOADER_NAME = {
[Downloader.RealDebrid]: "Real-Debrid",

View file

@ -109,20 +109,19 @@ export function GameDetailsContextProvider({
}, [objectID, gameTitle, dispatch]);
useEffect(() => {
const listeners = [
window.electron.onGameClose(() => {
if (isGameRunning) setisGameRunning(false);
}),
window.electron.onPlaytime((gameId) => {
if (gameId === game?.id) {
if (!isGameRunning) setisGameRunning(true);
updateGame();
}
}),
];
const unsubscribe = window.electron.onGamesRunning((gamesIds) => {
const updatedIsGameRunning =
!!game?.id &&
!!gamesIds.find((gameRunning) => gameRunning.id == game.id);
if (isGameRunning != updatedIsGameRunning) {
updateGame();
}
setisGameRunning(updatedIsGameRunning);
});
return () => {
listeners.forEach((unsubscribe) => unsubscribe());
unsubscribe();
};
}, [game?.id, isGameRunning, updateGame]);

View file

@ -13,6 +13,7 @@ import type {
StartGameDownloadPayload,
RealDebridUser,
DownloadSource,
UserProfile,
} from "@types";
import type { DiskSpace } from "check-disk-space";
@ -70,8 +71,12 @@ declare global {
removeGame: (gameId: number) => Promise<void>;
deleteGameFolder: (gameId: number) => Promise<unknown>;
getGameByObjectID: (objectID: string) => Promise<Game | null>;
onPlaytime: (cb: (gameId: number) => void) => () => Electron.IpcRenderer;
onGameClose: (cb: (gameId: number) => void) => () => Electron.IpcRenderer;
onGamesRunning: (
cb: (
gamesRunning: Pick<GameRunning, "id" | "sessionDurationInMillis">[]
) => void
) => () => Electron.IpcRenderer;
onLibraryBatchComplete: (cb: () => void) => () => Electron.IpcRenderer;
/* User preferences */
getUserPreferences: () => Promise<UserPreferences | null>;
@ -95,6 +100,7 @@ declare global {
/* Misc */
openExternal: (src: string) => Promise<void>;
isUserLoggedIn: () => Promise<boolean>;
getVersion: () => Promise<string>;
ping: () => string;
getDefaultDownloadsPath: () => Promise<string>;
@ -109,6 +115,23 @@ declare global {
) => () => Electron.IpcRenderer;
checkForUpdates: () => Promise<boolean>;
restartAndInstallUpdate: () => Promise<void>;
/* Auth */
signOut: () => Promise<void>;
openAuthWindow: () => Promise<void>;
getSessionHash: () => Promise<string | null>;
onSignIn: (cb: () => void) => () => Electron.IpcRenderer;
onSignOut: (cb: () => void) => () => Electron.IpcRenderer;
/* User */
getUser: (userId: string) => Promise<UserProfile | null>;
/* Profile */
getMe: () => Promise<UserProfile | null>;
updateProfile: (
displayName: string,
newProfileImagePath: string | null
) => Promise<UserProfile>;
}
interface Window {

View file

@ -4,3 +4,5 @@ export * from "./use-preferences-slice";
export * from "./download-slice";
export * from "./window-slice";
export * from "./toast-slice";
export * from "./user-details-slice";
export * from "./running-game-slice";

View file

@ -0,0 +1,22 @@
import { PayloadAction, createSlice } from "@reduxjs/toolkit";
import { GameRunning } from "@types";
export interface GameRunningState {
gameRunning: GameRunning | null;
}
const initialState: GameRunningState = {
gameRunning: null,
};
export const gameRunningSlice = createSlice({
name: "running-game",
initialState,
reducers: {
setGameRunning: (state, action: PayloadAction<GameRunning | null>) => {
state.gameRunning = action.payload;
},
},
});
export const { setGameRunning } = gameRunningSlice.actions;

View file

@ -0,0 +1,28 @@
import { PayloadAction, createSlice } from "@reduxjs/toolkit";
import type { UserDetails } from "@types";
export interface UserDetailsState {
userDetails: UserDetails | null;
profileBackground: null | string;
}
const initialState: UserDetailsState = {
userDetails: null,
profileBackground: null,
};
export const userDetailsSlice = createSlice({
name: "user-details",
initialState,
reducers: {
setUserDetails: (state, action: PayloadAction<UserDetails | null>) => {
state.userDetails = action.payload;
},
setProfileBackground: (state, action: PayloadAction<string | null>) => {
state.profileBackground = action.payload;
},
},
});
export const { setUserDetails, setProfileBackground } =
userDetailsSlice.actions;

View file

@ -1,5 +1,7 @@
import type { GameShop } from "@types";
import Color from "color";
export const steamUrlBuilder = {
library: (objectID: string) =>
`https://steamcdn-a.akamaihd.net/steam/apps/${objectID}/header.jpg`,
@ -40,3 +42,6 @@ export const buildGameDetailsPath = (
const searchParams = new URLSearchParams({ title: game.title, ...params });
return `/game/${game.shop}/${game.objectID}?${searchParams.toString()}`;
};
export const darkenColor = (color: string, amount: number, alpha: number = 1) =>
new Color(color).darken(amount).alpha(alpha).toString();

View file

@ -3,3 +3,4 @@ export * from "./use-library";
export * from "./use-date";
export * from "./use-toast";
export * from "./redux";
export * from "./use-user-details";

View file

@ -1,4 +1,4 @@
import { formatDistance } from "date-fns";
import { formatDistance, subMilliseconds } from "date-fns";
import type { FormatDistanceOptions } from "date-fns";
import {
ptBR,
@ -52,5 +52,20 @@ export function useDate() {
return "";
}
},
formatDiffInMillis: (
millis: number,
baseDate: string | number | Date,
options?: FormatDistanceOptions
) => {
try {
return formatDistance(subMilliseconds(new Date(), millis), baseDate, {
...options,
locale: getDateLocale(),
});
} catch (err) {
return "";
}
},
};
}

View file

@ -0,0 +1,84 @@
import { useCallback } from "react";
import { average } from "color.js";
import { useAppDispatch, useAppSelector } from "./redux";
import { setProfileBackground, setUserDetails } from "@renderer/features";
import { darkenColor } from "@renderer/helpers";
import { UserDetails } from "@types";
export function useUserDetails() {
const dispatch = useAppDispatch();
const { userDetails, profileBackground } = useAppSelector(
(state) => state.userDetails
);
const clearUserDetails = useCallback(async () => {
dispatch(setUserDetails(null));
dispatch(setProfileBackground(null));
window.localStorage.removeItem("userDetails");
}, [dispatch]);
const signOut = useCallback(async () => {
clearUserDetails();
return window.electron.signOut();
}, [clearUserDetails]);
const updateUserDetails = useCallback(
async (userDetails: UserDetails) => {
dispatch(setUserDetails(userDetails));
if (userDetails.profileImageUrl) {
const output = await average(userDetails.profileImageUrl, {
amount: 1,
format: "hex",
});
const profileBackground = `linear-gradient(135deg, ${darkenColor(output as string, 0.6)}, ${darkenColor(output as string, 0.8, 0.7)})`;
dispatch(setProfileBackground(profileBackground));
window.localStorage.setItem(
"userDetails",
JSON.stringify({ ...userDetails, profileBackground })
);
} else {
const profileBackground = `#151515B3`;
dispatch(setProfileBackground(profileBackground));
window.localStorage.setItem(
"userDetails",
JSON.stringify({ ...userDetails, profileBackground })
);
}
},
[dispatch]
);
const fetchUserDetails = useCallback(async () => {
return window.electron.getMe();
}, []);
const patchUser = useCallback(
async (displayName: string, imageProfileUrl: string | null) => {
const response = await window.electron.updateProfile(
displayName,
imageProfileUrl
);
return updateUserDetails(response);
},
[updateUserDetails]
);
return {
userDetails,
fetchUserDetails,
signOut,
clearUserDetails,
updateUserDetails,
patchUser,
profileBackground,
};
}

View file

@ -27,6 +27,7 @@ import {
import { store } from "./store";
import * as resources from "@locales";
import { User } from "./pages/user/user";
i18n
.use(LanguageDetector)
@ -54,6 +55,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
<Route path="/game/:shop/:objectID" Component={GameDetails} />
<Route path="/search" Component={SearchResults} />
<Route path="/settings" Component={Settings} />
<Route path="/user/:userId" Component={User} />
</Route>
</Routes>
</HashRouter>

View file

@ -138,8 +138,9 @@ export const randomizerButton = style({
bottom: `${26 + SPACING_UNIT * 2}px`,
/* Scroll bar + spacing */
right: `${9 + SPACING_UNIT * 2}px`,
boxShadow: "rgba(255, 255, 255, 0.1) 0px 0px 10px 3px",
boxShadow: "rgba(255, 255, 255, 0.1) 0px 0px 10px 1px",
border: `solid 2px ${vars.color.border}`,
zIndex: "1",
backgroundColor: vars.color.background,
":hover": {
backgroundColor: vars.color.background,
@ -149,6 +150,12 @@ export const randomizerButton = style({
":active": {
transform: "scale(0.98)",
},
":disabled": {
boxShadow: "none",
transform: "none",
opacity: "0.8",
backgroundColor: vars.color.background,
},
});
export const heroPanelSkeleton = style({

View file

@ -27,6 +27,7 @@ import { Downloader } from "@shared";
export function GameDetails() {
const [randomGame, setRandomGame] = useState<Steam250Game | null>(null);
const [randomizerLocked, setRandomizerLocked] = useState(false);
const { objectID } = useParams();
const [searchParams] = useSearchParams();
@ -54,6 +55,18 @@ export function GameDetails() {
{ fromRandomizer: "1" }
)
);
setRandomizerLocked(true);
const zero = performance.now();
requestAnimationFrame(function animateLock(time) {
if (time - zero <= 1000) {
requestAnimationFrame(animateLock);
} else {
setRandomizerLocked(false);
}
});
}
};
@ -118,7 +131,7 @@ export function GameDetails() {
className={styles.randomizerButton}
onClick={handleRandomizerClick}
theme="outline"
disabled={!randomGame}
disabled={!randomGame || randomizerLocked}
>
<div style={{ width: 16, height: 16, position: "relative" }}>
<Lottie

View file

@ -1,5 +1,4 @@
import { style } from "@vanilla-extract/css";
import { recipe } from "@vanilla-extract/recipes";
import { SPACING_UNIT } from "../../theme.css";
@ -17,11 +16,9 @@ export const content = style({
flex: "1",
});
export const cards = recipe({
base: {
display: "grid",
gridTemplateColumns: "repeat(2, 1fr)",
gap: `${SPACING_UNIT * 2}px`,
transition: "all ease 0.2s",
},
export const cards = style({
display: "grid",
gridTemplateColumns: "repeat(2, 1fr)",
gap: `${SPACING_UNIT * 2}px`,
transition: "all ease 0.2s",
});

View file

@ -65,7 +65,7 @@ export function AddDownloadSourceModal({
>
<TextField
label={t("download_source_url")}
placeholder="Insert a valid JSON url"
placeholder={t("insert_valid_json_url")}
value={value}
onChange={(e) => setValue(e.target.value)}
rightContent={
@ -99,14 +99,16 @@ export function AddDownloadSourceModal({
>
<h4>{validationResult?.name}</h4>
<small>
Found{" "}
{validationResult?.downloadCount.toLocaleString(undefined)}{" "}
download options
{t("found_download_option", {
count: validationResult?.downloadCount,
countFormatted:
validationResult?.downloadCount.toLocaleString(),
})}
</small>
</div>
<Button type="button" onClick={handleAddDownloadSource}>
Import
{t("import")}
</Button>
</div>
)}

View file

@ -0,0 +1,318 @@
import { UserGame, UserProfile } from "@types";
import cn from "classnames";
import * as styles from "./user.css";
import { SPACING_UNIT, vars } from "@renderer/theme.css";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
import {
useAppSelector,
useDate,
useToast,
useUserDetails,
} from "@renderer/hooks";
import { useNavigate } from "react-router-dom";
import { buildGameDetailsPath, steamUrlBuilder } from "@renderer/helpers";
import { PersonIcon, TelescopeIcon } from "@primer/octicons-react";
import { Button, Link } from "@renderer/components";
import { UserEditProfileModal } from "./user-edit-modal";
import { UserSignOutModal } from "./user-signout-modal";
const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120;
export interface ProfileContentProps {
userProfile: UserProfile;
updateUserProfile: () => Promise<void>;
}
export function UserContent({
userProfile,
updateUserProfile,
}: ProfileContentProps) {
const { t, i18n } = useTranslation("user_profile");
const { userDetails, profileBackground, signOut } = useUserDetails();
const { showSuccessToast } = useToast();
const [showEditProfileModal, setShowEditProfileModal] = useState(false);
const [showSignOutModal, setShowSignOutModal] = useState(false);
const { gameRunning } = useAppSelector((state) => state.gameRunning);
const navigate = useNavigate();
const numberFormatter = useMemo(() => {
return new Intl.NumberFormat(i18n.language, {
maximumFractionDigits: 0,
});
}, [i18n.language]);
const { formatDistance, formatDiffInMillis } = useDate();
const formatPlayTime = () => {
const seconds = userProfile.libraryGames.reduce(
(acc, game) => acc + game.playTimeInSeconds,
0
);
const minutes = seconds / 60;
if (minutes < MAX_MINUTES_TO_SHOW_IN_PLAYTIME) {
return t("amount_minutes", {
amount: minutes.toFixed(0),
});
}
const hours = minutes / 60;
return t("amount_hours", { amount: numberFormatter.format(hours) });
};
const handleGameClick = (game: UserGame) => {
navigate(buildGameDetailsPath(game));
};
const handleEditProfile = () => {
setShowEditProfileModal(true);
};
const handleConfirmSignout = async () => {
await signOut();
showSuccessToast(t("successfully_signed_out"));
navigate("/");
};
const isMe = userDetails?.id == userProfile.id;
const profileContentBoxBackground = useMemo(() => {
if (profileBackground) return profileBackground;
/* TODO: Render background colors for other users */
return undefined;
}, [profileBackground]);
return (
<>
<UserEditProfileModal
visible={showEditProfileModal}
onClose={() => setShowEditProfileModal(false)}
updateUserProfile={updateUserProfile}
userProfile={userProfile}
/>
<UserSignOutModal
visible={showSignOutModal}
onClose={() => setShowSignOutModal(false)}
onConfirm={handleConfirmSignout}
/>
<section
className={styles.profileContentBox}
style={{
padding: `${SPACING_UNIT * 3}px ${SPACING_UNIT * 2}px`,
position: "relative",
}}
>
{gameRunning && isMe && (
<img
src={steamUrlBuilder.libraryHero(gameRunning.objectID)}
alt={gameRunning.title}
className={styles.profileBackground}
/>
)}
<div
style={{
background: profileContentBoxBackground,
position: "absolute",
inset: 0,
borderRadius: "4px",
}}
></div>
<div className={styles.profileAvatarContainer}>
{userProfile.profileImageUrl ? (
<img
className={styles.profileAvatar}
alt={userProfile.displayName}
src={userProfile.profileImageUrl}
/>
) : (
<PersonIcon size={72} />
)}
</div>
<div className={styles.profileInformation}>
<h2 style={{ fontWeight: "bold" }}>{userProfile.displayName}</h2>
{isMe && gameRunning && (
<div
style={{
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT / 2}px`,
}}
>
<div
style={{
display: "flex",
flexDirection: "row",
gap: `${SPACING_UNIT}px`,
alignItems: "center",
}}
>
<Link to={buildGameDetailsPath(gameRunning)}>
{gameRunning.title}
</Link>
</div>
<small>
{t("playing_for", {
amount: formatDiffInMillis(
gameRunning.sessionDurationInMillis,
new Date()
),
})}
</small>
</div>
)}
</div>
{isMe && (
<div
style={{
flex: 1,
display: "flex",
justifyContent: "end",
zIndex: 1,
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT}px`,
}}
>
<>
<Button theme="outline" onClick={handleEditProfile}>
{t("edit_profile")}
</Button>
<Button
theme="danger"
onClick={() => setShowSignOutModal(true)}
>
{t("sign_out")}
</Button>
</>
</div>
</div>
)}
</section>
<div className={styles.profileContent}>
<div className={styles.profileGameSection}>
<h2>{t("activity")}</h2>
{!userProfile.recentGames.length ? (
<div className={styles.noDownloads}>
<div className={styles.telescopeIcon}>
<TelescopeIcon size={24} />
</div>
<h2>{t("no_recent_activity_title")}</h2>
<p style={{ fontFamily: "Fira Sans" }}>
{t("no_recent_activity_description")}
</p>
</div>
) : (
<div
style={{
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT * 2}px`,
}}
>
{userProfile.recentGames.map((game) => (
<button
key={game.objectID}
className={cn(styles.feedItem, styles.profileContentBox)}
onClick={() => handleGameClick(game)}
>
<img
className={styles.feedGameIcon}
src={game.cover}
alt={game.title}
/>
<div className={styles.gameInformation}>
<h4>{game.title}</h4>
<small>
{t("last_time_played", {
period: formatDistance(
game.lastTimePlayed!,
new Date(),
{
addSuffix: true,
}
),
})}
</small>
</div>
</button>
))}
</div>
)}
</div>
<div className={cn(styles.contentSidebar, styles.profileGameSection)}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: `${SPACING_UNIT * 2}px`,
}}
>
<h2>{t("library")}</h2>
<div
style={{
flex: 1,
backgroundColor: vars.color.border,
height: "1px",
}}
/>
<h3 style={{ fontWeight: "400" }}>
{userProfile.libraryGames.length}
</h3>
</div>
<small>{t("total_play_time", { amount: formatPlayTime() })}</small>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(4, 1fr)",
gap: `${SPACING_UNIT}px`,
}}
>
{userProfile.libraryGames.map((game) => (
<button
key={game.objectID}
className={cn(styles.gameListItem, styles.profileContentBox)}
onClick={() => handleGameClick(game)}
title={game.title}
>
{game.iconUrl ? (
<img
className={styles.libraryGameIcon}
src={game.iconUrl}
alt={game.title}
/>
) : (
<SteamLogo className={styles.libraryGameIcon} />
)}
</button>
))}
</div>
</div>
</div>
</>
);
}

View file

@ -0,0 +1,147 @@
import { Button, Modal, TextField } from "@renderer/components";
import { UserProfile } from "@types";
import * as styles from "./user.css";
import { DeviceCameraIcon, PersonIcon } from "@primer/octicons-react";
import { SPACING_UNIT } from "@renderer/theme.css";
import { useEffect, useMemo, useState } from "react";
import { useToast, useUserDetails } from "@renderer/hooks";
import { useTranslation } from "react-i18next";
export interface UserEditProfileModalProps {
userProfile: UserProfile;
visible: boolean;
onClose: () => void;
updateUserProfile: () => Promise<void>;
}
export const UserEditProfileModal = ({
userProfile,
visible,
onClose,
updateUserProfile,
}: UserEditProfileModalProps) => {
const { t } = useTranslation("user_profile");
const [displayName, setDisplayName] = useState("");
const [newImagePath, setNewImagePath] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
const { patchUser } = useUserDetails();
const { showSuccessToast, showErrorToast } = useToast();
useEffect(() => {
setDisplayName(userProfile.displayName);
}, [userProfile.displayName]);
const handleChangeProfileAvatar = async () => {
const { filePaths } = await window.electron.showOpenDialog({
properties: ["openFile"],
filters: [
{
name: "Image",
extensions: ["jpg", "jpeg", "png", "gif", "webp", "bmp"],
},
],
});
if (filePaths && filePaths.length > 0) {
const path = filePaths[0];
setNewImagePath(path);
}
};
const handleSaveProfile: React.FormEventHandler<HTMLFormElement> = async (
event
) => {
event.preventDefault();
setIsSaving(true);
patchUser(displayName, newImagePath)
.then(async () => {
await updateUserProfile();
showSuccessToast(t("saved_successfully"));
cleanFormAndClose();
})
.catch(() => {
showErrorToast(t("try_again"));
})
.finally(() => {
setIsSaving(false);
});
};
const resetModal = () => {
setDisplayName(userProfile.displayName);
setNewImagePath(null);
};
const cleanFormAndClose = () => {
resetModal();
onClose();
};
const avatarUrl = useMemo(() => {
if (newImagePath) return `local:${newImagePath}`;
if (userProfile.profileImageUrl) return userProfile.profileImageUrl;
return null;
}, [newImagePath, userProfile.profileImageUrl]);
return (
<>
<Modal
visible={visible}
title={t("edit_profile")}
onClose={cleanFormAndClose}
>
<form
onSubmit={handleSaveProfile}
style={{
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
gap: `${SPACING_UNIT * 3}px`,
width: "350px",
}}
>
<button
type="button"
className={styles.profileAvatarEditContainer}
onClick={handleChangeProfileAvatar}
>
{avatarUrl ? (
<img
className={styles.profileAvatar}
alt={userProfile.displayName}
src={avatarUrl}
/>
) : (
<PersonIcon size={96} />
)}
<div className={styles.editProfileImageBadge}>
<DeviceCameraIcon size={16} />
</div>
</button>
<TextField
label={t("display_name")}
value={displayName}
required
minLength={3}
containerProps={{ style: { width: "100%" } }}
onChange={(e) => setDisplayName(e.target.value)}
/>
<Button
disabled={isSaving}
style={{ alignSelf: "end" }}
type="submit"
>
{isSaving ? t("saving") : t("save")}
</Button>
</form>
</Modal>
</>
);
};

View file

@ -0,0 +1,40 @@
import { Button, Modal } from "@renderer/components";
import * as styles from "./user.css";
import { useTranslation } from "react-i18next";
export interface UserEditProfileModalProps {
visible: boolean;
onConfirm: () => void;
onClose: () => void;
}
export const UserSignOutModal = ({
visible,
onConfirm,
onClose,
}: UserEditProfileModalProps) => {
const { t } = useTranslation("user_profile");
return (
<>
<Modal
visible={visible}
title={t("sign_out_modal_title")}
onClose={onClose}
>
<div className={styles.signOutModalContent}>
<p style={{ fontFamily: "Fira Sans" }}>{t("sign_out_modal_text")}</p>
<div className={styles.signOutModalButtonsContainer}>
<Button onClick={onConfirm} theme="danger">
{t("sign_out")}
</Button>
<Button onClick={onClose} theme="primary">
{t("cancel")}
</Button>
</div>
</div>
</Modal>
</>
);
};

View file

@ -0,0 +1,41 @@
import Skeleton from "react-loading-skeleton";
import cn from "classnames";
import * as styles from "./user.css";
import { SPACING_UNIT } from "@renderer/theme.css";
import { useTranslation } from "react-i18next";
export const UserSkeleton = () => {
const { t } = useTranslation("user_profile");
return (
<>
<Skeleton className={styles.profileHeaderSkeleton} />
<div className={styles.profileContent}>
<div className={styles.profileGameSection}>
<h2>{t("activity")}</h2>
{Array.from({ length: 3 }).map((_, index) => (
<Skeleton
key={index}
height={72}
style={{ flex: "1", width: "100%" }}
/>
))}
</div>
<div className={cn(styles.contentSidebar, styles.profileGameSection)}>
<h2>{t("library")}</h2>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(4, 1fr)",
gap: `${SPACING_UNIT}px`,
}}
>
{Array.from({ length: 8 }).map((_, index) => (
<Skeleton key={index} style={{ aspectRatio: "1" }} />
))}
</div>
</div>
</div>
</>
);
};

View file

@ -0,0 +1,217 @@
import { SPACING_UNIT, vars } from "../../theme.css";
import { style } from "@vanilla-extract/css";
export const wrapper = style({
padding: "24px",
width: "100%",
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT * 3}px`,
});
export const profileContentBox = style({
display: "flex",
gap: `${SPACING_UNIT * 3}px`,
alignItems: "center",
borderRadius: "4px",
border: `solid 1px ${vars.color.border}`,
width: "100%",
boxShadow: "0px 0px 15px 0px rgba(0, 0, 0, 0.7)",
transition: "all ease 0.3s",
});
export const profileAvatarContainer = style({
width: "96px",
height: "96px",
borderRadius: "50%",
display: "flex",
justifyContent: "center",
alignItems: "center",
backgroundColor: vars.color.background,
position: "relative",
overflow: "hidden",
border: `solid 1px ${vars.color.border}`,
boxShadow: "0px 0px 5px 0px rgba(0, 0, 0, 0.7)",
zIndex: 1,
});
export const profileAvatarEditContainer = style({
width: "128px",
height: "128px",
display: "flex",
borderRadius: "50%",
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%",
borderRadius: "50%",
overflow: "hidden",
objectFit: "cover",
});
export const profileAvatarEditOverlay = style({
position: "absolute",
width: "100%",
height: "100%",
backgroundColor: "#00000055",
color: vars.color.muted,
zIndex: 1,
cursor: "pointer",
});
export const profileInformation = style({
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT}px`,
alignItems: "flex-start",
color: "#c0c1c7",
zIndex: 1,
});
export const profileContent = style({
display: "flex",
height: "100%",
flexDirection: "row",
gap: `${SPACING_UNIT * 4}px`,
});
export const profileGameSection = style({
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT * 2}px`,
});
export const contentSidebar = style({
width: "100%",
"@media": {
"(min-width: 768px)": {
width: "100%",
maxWidth: "150px",
},
"(min-width: 1024px)": {
maxWidth: "250px",
width: "100%",
},
},
});
export const feedGameIcon = style({
height: "100%",
});
export const libraryGameIcon = style({
width: "100%",
height: "100%",
borderRadius: "4px",
});
export const feedItem = style({
color: vars.color.body,
display: "flex",
flexDirection: "row",
gap: `${SPACING_UNIT * 2}px`,
width: "100%",
height: "72px",
transition: "all ease 0.2s",
cursor: "pointer",
zIndex: "1",
":hover": {
backgroundColor: "rgba(255, 255, 255, 0.15)",
},
});
export const gameListItem = style({
color: vars.color.body,
transition: "all ease 0.2s",
cursor: "pointer",
zIndex: "1",
overflow: "hidden",
padding: `${SPACING_UNIT + SPACING_UNIT / 2}px`,
":hover": {
backgroundColor: "rgba(255, 255, 255, 0.15)",
},
});
export const gameInformation = style({
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
gap: `${SPACING_UNIT / 2}px`,
});
export const profileHeaderSkeleton = style({
height: "144px",
});
export const editProfileImageBadge = style({
width: "28px",
height: "28px",
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: vars.color.background,
backgroundColor: vars.color.muted,
position: "absolute",
bottom: "0px",
right: "0px",
zIndex: "1",
});
export const telescopeIcon = style({
width: "60px",
height: "60px",
borderRadius: "50%",
backgroundColor: "rgba(255, 255, 255, 0.06)",
display: "flex",
alignItems: "center",
justifyContent: "center",
marginBottom: `${SPACING_UNIT * 2}px`,
});
export const noDownloads = style({
display: "flex",
width: "100%",
height: "100%",
justifyContent: "center",
alignItems: "center",
flexDirection: "column",
gap: `${SPACING_UNIT}px`,
});
export const signOutModalContent = style({
display: "flex",
width: "100%",
flexDirection: "column",
gap: `${SPACING_UNIT}px`,
});
export const signOutModalButtonsContainer = style({
display: "flex",
width: "100%",
justifyContent: "end",
alignItems: "center",
gap: `${SPACING_UNIT}px`,
paddingTop: `${SPACING_UNIT}px`,
});
export const profileBackground = style({
width: "100%",
height: "100%",
position: "absolute",
objectFit: "cover",
left: "0",
top: "0",
borderRadius: "4px",
});

View file

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

View file

@ -6,6 +6,8 @@ import {
searchSlice,
userPreferencesSlice,
toastSlice,
userDetailsSlice,
gameRunningSlice,
} from "@renderer/features";
export const store = configureStore({
@ -16,6 +18,8 @@ export const store = configureStore({
userPreferences: userPreferencesSlice.reducer,
download: downloadSlice.reducer,
toast: toastSlice.reducer,
userDetails: userDetailsSlice.reducer,
gameRunning: gameRunningSlice.reducer,
},
});

View file

@ -21,4 +21,10 @@ export const vars = createGlobalTheme(":root", {
body: "14px",
small: "12px",
},
zIndex: {
toast: "2",
bottomPanel: "3",
titleBar: "4",
backdrop: "4",
},
});