mirror of
https://github.com/hydralauncher/hydra.git
synced 2025-03-09 15:40:26 +00:00
Merge pull request #601 from hydralauncher/feature/sync-library
feat: sync library with api
This commit is contained in:
commit
b337fd8d64
41 changed files with 1168 additions and 39 deletions
|
@ -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: https://cdn.losbroxas.org 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://cdn.losbroxas.org 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">
|
||||
|
|
|
@ -7,6 +7,7 @@ import {
|
|||
useAppSelector,
|
||||
useDownload,
|
||||
useLibrary,
|
||||
useUserDetails,
|
||||
} from "@renderer/hooks";
|
||||
|
||||
import * as styles from "./app.css";
|
||||
|
@ -30,15 +31,19 @@ export function App() {
|
|||
|
||||
const { clearDownload, setLastPacket } = useDownload();
|
||||
|
||||
const { updateUser, clearUser } = 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);
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -67,6 +72,28 @@ export function App() {
|
|||
};
|
||||
}, [clearDownload, setLastPacket, updateLibrary]);
|
||||
|
||||
useEffect(() => {
|
||||
updateUser();
|
||||
}, [updateUser]);
|
||||
|
||||
useEffect(() => {
|
||||
const listeners = [
|
||||
window.electron.onSignIn(() => {
|
||||
updateUser();
|
||||
}),
|
||||
window.electron.onLibraryBatchComplete(() => {
|
||||
updateLibrary();
|
||||
}),
|
||||
window.electron.onSignOut(() => {
|
||||
clearUser();
|
||||
}),
|
||||
];
|
||||
|
||||
return () => {
|
||||
listeners.forEach((unsubscribe) => unsubscribe());
|
||||
};
|
||||
}, [updateUser, updateLibrary, clearUser]);
|
||||
|
||||
const handleSearch = useCallback(
|
||||
(query: string) => {
|
||||
dispatch(setSearch(query));
|
||||
|
|
|
@ -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("/profile")) return headerTitle;
|
||||
if (location.pathname.startsWith("/search")) return t("search_results");
|
||||
|
||||
return t(pathTitle[location.pathname]);
|
||||
|
|
71
src/renderer/src/components/sidebar/sidebar-profile.tsx
Normal file
71
src/renderer/src/components/sidebar/sidebar-profile.tsx
Normal file
|
@ -0,0 +1,71 @@
|
|||
import { useNavigate } from "react-router-dom";
|
||||
import { PersonIcon } from "@primer/octicons-react";
|
||||
import * as styles from "./sidebar.css";
|
||||
import { useUserDetails } from "@renderer/hooks";
|
||||
import { useMemo } from "react";
|
||||
|
||||
export function SidebarProfile() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { userDetails, profileBackground } = useUserDetails();
|
||||
|
||||
const handleClickProfile = () => {
|
||||
navigate(`/user/${userDetails!.id}`);
|
||||
};
|
||||
|
||||
const handleClickLogin = () => {
|
||||
window.electron.openExternal("https://auth.hydra.losbroxas.org");
|
||||
};
|
||||
|
||||
const profileButtonBackground = useMemo(() => {
|
||||
if (profileBackground) return profileBackground;
|
||||
return undefined;
|
||||
}, [profileBackground]);
|
||||
|
||||
if (userDetails == null) {
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.profileButton}
|
||||
onClick={handleClickLogin}
|
||||
>
|
||||
<div className={styles.profileAvatar}>
|
||||
<PersonIcon />
|
||||
</div>
|
||||
|
||||
<div className={styles.profileButtonInformation}>
|
||||
<p style={{ fontWeight: "bold" }}>Fazer login</p>
|
||||
</div>
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.profileButton}
|
||||
style={{ background: profileButtonBackground }}
|
||||
onClick={handleClickProfile}
|
||||
>
|
||||
<div className={styles.profileAvatar}>
|
||||
{userDetails.profileImageUrl ? (
|
||||
<img
|
||||
className={styles.profileAvatar}
|
||||
src={userDetails.profileImageUrl}
|
||||
alt={userDetails.displayName}
|
||||
/>
|
||||
) : (
|
||||
<PersonIcon />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.profileButtonInformation}>
|
||||
<p style={{ fontWeight: "bold" }}>{userDetails.displayName}</p>
|
||||
</div>
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -149,7 +149,9 @@ export const profileAvatar = style({
|
|||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
backgroundColor: vars.color.background,
|
||||
border: `solid 1px ${vars.color.border}`,
|
||||
position: "relative",
|
||||
objectFit: "cover",
|
||||
});
|
||||
|
||||
export const profileButtonInformation = style({
|
||||
|
|
|
@ -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({
|
||||
|
|
13
src/renderer/src/declaration.d.ts
vendored
13
src/renderer/src/declaration.d.ts
vendored
|
@ -13,6 +13,7 @@ import type {
|
|||
StartGameDownloadPayload,
|
||||
RealDebridUser,
|
||||
DownloadSource,
|
||||
UserProfile,
|
||||
} from "@types";
|
||||
import type { DiskSpace } from "check-disk-space";
|
||||
|
||||
|
@ -72,6 +73,7 @@ declare global {
|
|||
getGameByObjectID: (objectID: string) => Promise<Game | null>;
|
||||
onPlaytime: (cb: (gameId: number) => void) => () => Electron.IpcRenderer;
|
||||
onGameClose: (cb: (gameId: number) => void) => () => Electron.IpcRenderer;
|
||||
onLibraryBatchComplete: (cb: () => void) => () => Electron.IpcRenderer;
|
||||
|
||||
/* User preferences */
|
||||
getUserPreferences: () => Promise<UserPreferences | null>;
|
||||
|
@ -109,6 +111,17 @@ declare global {
|
|||
) => () => Electron.IpcRenderer;
|
||||
checkForUpdates: () => Promise<boolean>;
|
||||
restartAndInstallUpdate: () => Promise<void>;
|
||||
|
||||
/* Auth */
|
||||
signOut: () => Promise<void>;
|
||||
onSignIn: (cb: () => void) => () => Electron.IpcRenderer;
|
||||
onSignOut: (cb: () => void) => () => Electron.IpcRenderer;
|
||||
|
||||
/* User */
|
||||
getUser: (userId: string) => Promise<UserProfile | null>;
|
||||
|
||||
/* Profile */
|
||||
getMe: () => Promise<UserProfile | null>;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
|
|
|
@ -4,3 +4,4 @@ export * from "./use-preferences-slice";
|
|||
export * from "./download-slice";
|
||||
export * from "./window-slice";
|
||||
export * from "./toast-slice";
|
||||
export * from "./user-details-slice";
|
||||
|
|
32
src/renderer/src/features/user-details-slice.ts
Normal file
32
src/renderer/src/features/user-details-slice.ts
Normal file
|
@ -0,0 +1,32 @@
|
|||
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>) => {
|
||||
state.userDetails = action.payload;
|
||||
},
|
||||
setProfileBackground: (state, action: PayloadAction<string>) => {
|
||||
state.profileBackground = action.payload;
|
||||
},
|
||||
clearUserDetails: (state) => {
|
||||
state.userDetails = null;
|
||||
state.profileBackground = null;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { setUserDetails, setProfileBackground, clearUserDetails } =
|
||||
userDetailsSlice.actions;
|
|
@ -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) =>
|
||||
new Color(color).darken(amount).toString();
|
||||
|
|
|
@ -3,3 +3,4 @@ export * from "./use-library";
|
|||
export * from "./use-date";
|
||||
export * from "./use-toast";
|
||||
export * from "./redux";
|
||||
export * from "./use-user-details";
|
||||
|
|
57
src/renderer/src/hooks/use-user-details.ts
Normal file
57
src/renderer/src/hooks/use-user-details.ts
Normal file
|
@ -0,0 +1,57 @@
|
|||
import { useCallback } from "react";
|
||||
import { average } from "color.js";
|
||||
|
||||
import { useAppDispatch, useAppSelector } from "./redux";
|
||||
import {
|
||||
clearUserDetails,
|
||||
setProfileBackground,
|
||||
setUserDetails,
|
||||
} from "@renderer/features";
|
||||
import { darkenColor } from "@renderer/helpers";
|
||||
|
||||
export function useUserDetails() {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { userDetails, profileBackground } = useAppSelector(
|
||||
(state) => state.userDetails
|
||||
);
|
||||
|
||||
const clearUser = useCallback(async () => {
|
||||
dispatch(clearUserDetails());
|
||||
}, [dispatch]);
|
||||
|
||||
const signOut = useCallback(async () => {
|
||||
clearUser();
|
||||
|
||||
return window.electron.signOut();
|
||||
}, [clearUser]);
|
||||
|
||||
const updateUser = useCallback(async () => {
|
||||
return window.electron.getMe().then(async (userDetails) => {
|
||||
if (userDetails) {
|
||||
dispatch(setUserDetails(userDetails));
|
||||
|
||||
if (userDetails.profileImageUrl) {
|
||||
const output = await average(userDetails.profileImageUrl, {
|
||||
amount: 1,
|
||||
format: "hex",
|
||||
});
|
||||
|
||||
dispatch(
|
||||
setProfileBackground(
|
||||
`linear-gradient(135deg, ${darkenColor(output as string, 0.6)}, ${darkenColor(output as string, 0.7)})`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}, [dispatch]);
|
||||
|
||||
return {
|
||||
userDetails,
|
||||
updateUser,
|
||||
signOut,
|
||||
clearUser,
|
||||
profileBackground,
|
||||
};
|
||||
}
|
|
@ -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>
|
||||
|
|
205
src/renderer/src/pages/user/user-content.tsx
Normal file
205
src/renderer/src/pages/user/user-content.tsx
Normal file
|
@ -0,0 +1,205 @@
|
|||
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 } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
|
||||
import { useDate, useUserDetails } from "@renderer/hooks";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { buildGameDetailsPath } from "@renderer/helpers";
|
||||
import { PersonIcon } from "@primer/octicons-react";
|
||||
import { Button } from "@renderer/components";
|
||||
|
||||
const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120;
|
||||
|
||||
export interface ProfileContentProps {
|
||||
userProfile: UserProfile;
|
||||
}
|
||||
|
||||
export function UserContent({ userProfile }: ProfileContentProps) {
|
||||
const { t, i18n } = useTranslation("user_profile");
|
||||
|
||||
const { userDetails, profileBackground, signOut } = useUserDetails();
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const numberFormatter = useMemo(() => {
|
||||
return new Intl.NumberFormat(i18n.language, {
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
}, [i18n.language]);
|
||||
|
||||
const { formatDistance } = 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 handleSignout = async () => {
|
||||
await signOut();
|
||||
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 (
|
||||
<>
|
||||
<section
|
||||
className={styles.profileContentBox}
|
||||
style={{
|
||||
background: profileContentBoxBackground,
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{isMe && (
|
||||
<div style={{ flex: 1, display: "flex", justifyContent: "end" }}>
|
||||
<Button theme="danger" onClick={handleSignout}>
|
||||
{t("sign_out")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className={styles.profileContent}>
|
||||
<div className={styles.profileGameSection}>
|
||||
<div>
|
||||
<h2>{t("activity")}</h2>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: `${SPACING_UNIT * 2}px`,
|
||||
}}
|
||||
>
|
||||
{userProfile.recentGames.map((game) => {
|
||||
return (
|
||||
<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: "auto auto auto",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
}}
|
||||
>
|
||||
{userProfile.libraryGames.map((game) => {
|
||||
return (
|
||||
<button
|
||||
key={game.objectID}
|
||||
className={cn(styles.gameListItem, styles.profileContentBox)}
|
||||
style={{
|
||||
padding: `${SPACING_UNIT + SPACING_UNIT / 2}px`,
|
||||
}}
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
14
src/renderer/src/pages/user/user-skeleton.tsx
Normal file
14
src/renderer/src/pages/user/user-skeleton.tsx
Normal file
|
@ -0,0 +1,14 @@
|
|||
import Skeleton from "react-loading-skeleton";
|
||||
import * as styles from "./user.css";
|
||||
|
||||
export const UserSkeleton = () => {
|
||||
return (
|
||||
<>
|
||||
<Skeleton className={styles.profileHeaderSkeleton} />
|
||||
<div className={styles.profileContent}>
|
||||
<Skeleton height={140} style={{ flex: 1 }} />
|
||||
<Skeleton width={300} className={styles.contentSidebar} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
138
src/renderer/src/pages/user/user.css.ts
Normal file
138
src/renderer/src/pages/user/user.css.ts
Normal file
|
@ -0,0 +1,138 @@
|
|||
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`,
|
||||
padding: `${SPACING_UNIT * 4}px ${SPACING_UNIT * 2}px`,
|
||||
alignItems: "center",
|
||||
borderRadius: "4px",
|
||||
border: `solid 1px ${vars.color.border}`,
|
||||
width: "100%",
|
||||
overflow: "hidden",
|
||||
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)",
|
||||
});
|
||||
|
||||
export const profileAvatar = style({
|
||||
width: "96px",
|
||||
height: "96px",
|
||||
objectFit: "cover",
|
||||
});
|
||||
|
||||
export const profileInformation = style({
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "flex-start",
|
||||
color: "#c0c1c7",
|
||||
});
|
||||
|
||||
export const profileContent = style({
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
gap: `${SPACING_UNIT * 4}px`,
|
||||
});
|
||||
|
||||
export const profileGameSection = style({
|
||||
width: "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%",
|
||||
},
|
||||
"(min-width: 1280px)": {
|
||||
width: "100%",
|
||||
maxWidth: "350px",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const feedGameIcon = style({
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
position: "relative",
|
||||
});
|
||||
|
||||
export const libraryGameIcon = style({
|
||||
height: "100%",
|
||||
borderRadius: "4px",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
position: "relative",
|
||||
});
|
||||
|
||||
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,
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
aspectRatio: "1",
|
||||
transition: "all ease 0.2s",
|
||||
cursor: "pointer",
|
||||
zIndex: "1",
|
||||
":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: "200px",
|
||||
});
|
38
src/renderer/src/pages/user/user.tsx
Normal file
38
src/renderer/src/pages/user/user.tsx
Normal file
|
@ -0,0 +1,38 @@
|
|||
import { UserProfile } from "@types";
|
||||
import { 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();
|
||||
|
||||
useEffect(() => {
|
||||
window.electron.getUser(userId!).then((userProfile) => {
|
||||
if (userProfile) {
|
||||
dispatch(setHeaderTitle(userProfile.displayName));
|
||||
setUserProfile(userProfile);
|
||||
}
|
||||
});
|
||||
}, [dispatch, userId]);
|
||||
|
||||
return (
|
||||
<SkeletonTheme baseColor={vars.color.background} highlightColor="#444">
|
||||
<div className={styles.wrapper}>
|
||||
{userProfile ? (
|
||||
<UserContent userProfile={userProfile} />
|
||||
) : (
|
||||
<UserSkeleton />
|
||||
)}
|
||||
</div>
|
||||
</SkeletonTheme>
|
||||
);
|
||||
};
|
|
@ -6,6 +6,7 @@ import {
|
|||
searchSlice,
|
||||
userPreferencesSlice,
|
||||
toastSlice,
|
||||
userDetailsSlice,
|
||||
} from "@renderer/features";
|
||||
|
||||
export const store = configureStore({
|
||||
|
@ -16,6 +17,7 @@ export const store = configureStore({
|
|||
userPreferences: userPreferencesSlice.reducer,
|
||||
download: downloadSlice.reducer,
|
||||
toast: toastSlice.reducer,
|
||||
userDetails: userDetailsSlice.reducer,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue