Merge pull request #601 from hydralauncher/feature/sync-library

feat: sync library with api
This commit is contained in:
Chubby Granny Chaser 2024-06-20 00:50:08 +01:00 committed by GitHub
commit b337fd8d64
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 1168 additions and 39 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: 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">

View file

@ -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));

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("/profile")) return headerTitle;
if (location.pathname.startsWith("/search")) return t("search_results");
return t(pathTitle[location.pathname]);

View 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>
</>
);
}

View file

@ -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({

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

@ -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 {

View file

@ -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";

View 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;

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) =>
new Color(color).darken(amount).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

@ -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,
};
}

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

@ -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>
</>
);
}

View 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>
</>
);
};

View 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",
});

View 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>
);
};

View file

@ -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,
},
});