Merge pull request #1788 from hydralauncher/feat/pinning-games
Some checks failed
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-latest) (push) Has been cancelled

Feat: Pinning and favoriting games in profile
This commit is contained in:
Moyase
2025-09-25 16:18:13 +03:00
committed by GitHub
18 changed files with 278 additions and 15 deletions

View File

@@ -210,6 +210,8 @@
"download_error_not_cached_on_hydra": "This download is not available on Nimbus.",
"game_removed_from_favorites": "Game removed from favorites",
"game_added_to_favorites": "Game added to favorites",
"game_removed_from_pinned": "Game removed from pinned",
"game_added_to_pinned": "Game added to pinned",
"automatically_extract_downloaded_files": "Automatically extract downloaded files",
"create_start_menu_shortcut": "Create Start Menu shortcut",
"invalid_wine_prefix_path": "Invalid Wine prefix path",
@@ -451,6 +453,7 @@
"last_time_played": "Last played {{period}}",
"activity": "Recent Activity",
"library": "Library",
"pinned": "Pinned",
"total_play_time": "Total playtime",
"manual_playtime_tooltip": "This playtime has been manually updated",
"no_recent_activity_title": "Hmmm… nothing here",

View File

@@ -16,6 +16,8 @@ import "./hardware/check-folder-write-permission";
import "./library/add-game-to-library";
import "./library/add-game-to-favorites";
import "./library/remove-game-from-favorites";
import "./library/add-game-to-pinned";
import "./library/remove-game-from-pinned";
import "./library/create-game-shortcut";
import "./library/close-game";
import "./library/delete-game-folder";
@@ -64,6 +66,7 @@ import "./auth/sign-out";
import "./auth/open-auth-window";
import "./auth/get-session-hash";
import "./user/get-user";
import "./user/get-user-library";
import "./user/get-blocked-users";
import "./user/block-user";
import "./user/unblock-user";

View File

@@ -0,0 +1,29 @@
import { registerEvent } from "../register-event";
import { gamesSublevel, levelKeys } from "@main/level";
import { HydraApi } from "@main/services";
import type { GameShop } from "@types";
const addGameToPinned = async (
_event: Electron.IpcMainInvokeEvent,
shop: GameShop,
objectId: string
) => {
const gameKey = levelKeys.game(shop, objectId);
const game = await gamesSublevel.get(gameKey);
if (!game) return;
const response = await HydraApi.put(`/profile/games/${shop}/${objectId}/pin`);
try {
await gamesSublevel.put(gameKey, {
...game,
pinned: true,
pinnedDate: new Date(response.pinnedDate),
});
} catch (error) {
throw new Error(`Failed to update game pinned status: ${error}`);
}
};
registerEvent("addGameToPinned", addGameToPinned);

View File

@@ -0,0 +1,29 @@
import { registerEvent } from "../register-event";
import { gamesSublevel, levelKeys } from "@main/level";
import { HydraApi } from "@main/services";
import type { GameShop } from "@types";
const removeGameFromPinned = async (
_event: Electron.IpcMainInvokeEvent,
shop: GameShop,
objectId: string
) => {
const gameKey = levelKeys.game(shop, objectId);
const game = await gamesSublevel.get(gameKey);
if (!game) return;
HydraApi.put(`/profile/games/${shop}/${objectId}/unpin`).catch(() => {});
try {
await gamesSublevel.put(gameKey, {
...game,
pinned: false,
pinnedDate: null,
});
} catch (error) {
throw new Error(`Failed to update game pinned status: ${error}`);
}
};
registerEvent("removeGameFromPinned", removeGameFromPinned);

View File

@@ -0,0 +1,23 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import type { UserLibraryResponse } from "@types";
const getUserLibrary = async (
_event: Electron.IpcMainInvokeEvent,
userId: string,
take: number = 12,
skip: number = 0
): Promise<UserLibraryResponse | null> => {
const params = new URLSearchParams();
params.append("take", take.toString());
params.append("skip", skip.toString());
const queryString = params.toString();
const baseUrl = `/users/${userId}/library`;
const url = queryString ? `${baseUrl}?${queryString}` : baseUrl;
return HydraApi.get<UserLibraryResponse>(url).catch(() => null);
};
registerEvent("getUserLibrary", getUserLibrary);

View File

@@ -8,6 +8,7 @@ type ProfileGame = {
playTimeInMilliseconds: number;
hasManuallyUpdatedPlaytime: boolean;
isFavorite?: boolean;
isPinned?: boolean;
} & ShopAssets;
export const mergeWithRemoteGames = async () => {
@@ -36,6 +37,7 @@ export const mergeWithRemoteGames = async () => {
lastTimePlayed: updatedLastTimePlayed,
playTimeInMilliseconds: updatedPlayTime,
favorite: game.isFavorite ?? localGame.favorite,
pinned: game.isPinned ?? localGame.pinned,
});
} else {
await gamesSublevel.put(gameKey, {
@@ -49,6 +51,7 @@ export const mergeWithRemoteGames = async () => {
hasManuallyUpdatedPlaytime: game.hasManuallyUpdatedPlaytime,
isDeleted: false,
favorite: game.isFavorite ?? false,
pinned: game.isPinned ?? false,
});
}

View File

@@ -27,6 +27,7 @@ export const uploadGamesBatch = async () => {
shop: game.shop,
lastTimePlayed: game.lastTimePlayed,
isFavorite: game.favorite,
isPinned: game.pinned ?? false,
};
})
).catch(() => {});

View File

@@ -143,6 +143,10 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("addGameToFavorites", shop, objectId),
removeGameFromFavorites: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("removeGameFromFavorites", shop, objectId),
addGameToPinned: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("addGameToPinned", shop, objectId),
removeGameFromPinned: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("removeGameFromPinned", shop, objectId),
updateLaunchOptions: (
shop: GameShop,
objectId: string,
@@ -366,6 +370,8 @@ contextBridge.exposeInMainWorld("electron", {
/* User */
getUser: (userId: string) => ipcRenderer.invoke("getUser", userId),
getUserLibrary: (userId: string, take?: number, skip?: number) =>
ipcRenderer.invoke("getUserLibrary", userId, take, skip),
blockUser: (userId: string) => ipcRenderer.invoke("blockUser", userId),
unblockUser: (userId: string) => ipcRenderer.invoke("unblockUser", userId),
getUserFriends: (userId: string, take: number, skip: number) =>

View File

@@ -1,6 +1,6 @@
import { darkenColor } from "@renderer/helpers";
import { useAppSelector, useToast } from "@renderer/hooks";
import type { Badge, UserProfile, UserStats } from "@types";
import type { Badge, UserProfile, UserStats, UserGame } from "@types";
import { average } from "color.js";
import { createContext, useCallback, useEffect, useState } from "react";
@@ -17,6 +17,8 @@ export interface UserProfileContext {
setSelectedBackgroundImage: React.Dispatch<React.SetStateAction<string>>;
backgroundImage: string;
badges: Badge[];
libraryGames: UserGame[];
pinnedGames: UserGame[];
}
export const DEFAULT_USER_PROFILE_BACKGROUND = "#151515B3";
@@ -30,6 +32,8 @@ export const userProfileContext = createContext<UserProfileContext>({
setSelectedBackgroundImage: () => {},
backgroundImage: "",
badges: [],
libraryGames: [],
pinnedGames: [],
});
const { Provider } = userProfileContext;
@@ -49,6 +53,8 @@ export function UserProfileContextProvider({
const [userStats, setUserStats] = useState<UserStats | null>(null);
const [userProfile, setUserProfile] = useState<UserProfile | null>(null);
const [libraryGames, setLibraryGames] = useState<UserGame[]>([]);
const [pinnedGames, setPinnedGames] = useState<UserGame[]>([]);
const [badges, setBadges] = useState<Badge[]>([]);
const [heroBackground, setHeroBackground] = useState(
DEFAULT_USER_PROFILE_BACKGROUND
@@ -85,8 +91,25 @@ export function UserProfileContextProvider({
});
}, [userId]);
const getUserLibraryGames = useCallback(async () => {
try {
const response = await window.electron.getUserLibrary(userId);
if (response) {
setLibraryGames(response.library);
setPinnedGames(response.pinnedGames);
} else {
setLibraryGames([]);
setPinnedGames([]);
}
} catch (error) {
setLibraryGames([]);
setPinnedGames([]);
}
}, [userId]);
const getUserProfile = useCallback(async () => {
getUserStats();
getUserLibraryGames();
return window.electron.getUser(userId).then((userProfile) => {
if (userProfile) {
@@ -102,7 +125,7 @@ export function UserProfileContextProvider({
navigate(-1);
}
});
}, [navigate, getUserStats, showErrorToast, userId, t]);
}, [navigate, getUserStats, getUserLibraryGames, showErrorToast, userId, t]);
const getBadges = useCallback(async () => {
const badges = await window.electron.getBadges();
@@ -111,6 +134,8 @@ export function UserProfileContextProvider({
useEffect(() => {
setUserProfile(null);
setLibraryGames([]);
setPinnedGames([]);
setHeroBackground(DEFAULT_USER_PROFILE_BACKGROUND);
getUserProfile();
@@ -128,6 +153,8 @@ export function UserProfileContextProvider({
backgroundImage: getBackgroundImageUrl(),
userStats,
badges,
libraryGames,
pinnedGames,
}}
>
{children}

View File

@@ -37,6 +37,7 @@ import type {
ShopDetailsWithAssets,
AchievementCustomNotificationPosition,
AchievementNotificationInfo,
UserLibraryResponse,
} from "@types";
import type { AxiosProgressEvent } from "axios";
import type disk from "diskusage";
@@ -126,6 +127,8 @@ declare global {
shop: GameShop,
objectId: string
) => Promise<void>;
addGameToPinned: (shop: GameShop, objectId: string) => Promise<void>;
removeGameFromPinned: (shop: GameShop, objectId: string) => Promise<void>;
updateLaunchOptions: (
shop: GameShop,
objectId: string,
@@ -287,6 +290,11 @@ declare global {
/* User */
getUser: (userId: string) => Promise<UserProfile | null>;
getUserLibrary: (
userId: string,
take?: number,
skip?: number
) => Promise<UserLibraryResponse>;
blockUser: (userId: string) => Promise<void>;
unblockUser: (userId: string) => Promise<void>;
getUserFriends: (

View File

@@ -3,11 +3,18 @@ import {
GearIcon,
HeartFillIcon,
HeartIcon,
PinIcon,
PinSlashIcon,
PlayIcon,
PlusCircleIcon,
} from "@primer/octicons-react";
import { Button } from "@renderer/components";
import { useDownload, useLibrary, useToast } from "@renderer/hooks";
import {
useDownload,
useLibrary,
useToast,
useUserDetails,
} from "@renderer/hooks";
import { useContext, useState } from "react";
import { useTranslation } from "react-i18next";
import { gameDetailsContext } from "@renderer/context";
@@ -19,6 +26,7 @@ export function HeroPanelActions() {
useState(false);
const { isGameDeleting } = useDownload();
const { userDetails } = useUserDetails();
const {
game,
@@ -82,6 +90,29 @@ export function HeroPanelActions() {
}
};
const toggleGamePinned = async () => {
setToggleLibraryGameDisabled(true);
try {
if (game?.pinned && objectId) {
await window.electron.removeGameFromPinned(shop, objectId).then(() => {
showSuccessToast(t("game_removed_from_pinned"));
});
} else {
if (!objectId) return;
await window.electron.addGameToPinned(shop, objectId).then(() => {
showSuccessToast(t("game_added_to_pinned"));
});
}
updateLibrary();
updateGame();
} finally {
setToggleLibraryGameDisabled(false);
}
};
const openGame = async () => {
if (game) {
if (game.executablePath) {
@@ -198,6 +229,17 @@ export function HeroPanelActions() {
{game.favorite ? <HeartFillIcon /> : <HeartIcon />}
</Button>
{userDetails && (
<Button
onClick={toggleGamePinned}
theme="outline"
disabled={deleting}
className="hero-panel-actions__action"
>
{game.pinned ? <PinSlashIcon /> : <PinIcon />}
</Button>
)}
<Button
onClick={() => setShowGameOptionsModal(true)}
theme="outline"

View File

@@ -11,7 +11,6 @@ import "./hero-panel-playtime.scss";
export function HeroPanelPlaytime() {
const [lastTimePlayed, setLastTimePlayed] = useState("");
const { game, isGameRunning } = useContext(gameDetailsContext);
const { t } = useTranslation("game_details");
@@ -89,7 +88,7 @@ export function HeroPanelPlaytime() {
return (
<>
<p
<p
className="hero-panel-playtime__play-time"
data-tooltip-place="top"
data-tooltip-content={
@@ -97,11 +96,15 @@ export function HeroPanelPlaytime() {
? t("manual_playtime_tooltip")
: undefined
}
data-tooltip-id={game.hasManuallyUpdatedPlaytime ? "manual-playtime-warning" : undefined}
data-tooltip-id={
game.hasManuallyUpdatedPlaytime
? "manual-playtime-warning"
: undefined
}
>
{game.hasManuallyUpdatedPlaytime && (
<AlertFillIcon
size={16}
<AlertFillIcon
size={16}
className="hero-panel-playtime__manual-warning"
/>
)}
@@ -119,7 +122,7 @@ export function HeroPanelPlaytime() {
})}
</p>
)}
{game.hasManuallyUpdatedPlaytime && (
<Tooltip
id="manual-playtime-warning"
@@ -127,7 +130,6 @@ export function HeroPanelPlaytime() {
zIndex: 9999,
}}
openOnClick={false}
/>
)}
</>

View File

@@ -58,6 +58,34 @@
margin-bottom: calc(globals.$spacing-unit * 2);
}
&__tabs {
display: flex;
gap: calc(globals.$spacing-unit);
margin-bottom: calc(globals.$spacing-unit * 2);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
&__tab {
background: none;
border: none;
color: rgba(255, 255, 255, 0.6);
padding: calc(globals.$spacing-unit) calc(globals.$spacing-unit * 2);
cursor: pointer;
font-size: 14px;
font-weight: 500;
border-bottom: 2px solid transparent;
transition: all ease 0.2s;
&:hover {
color: rgba(255, 255, 255, 0.8);
}
&--active {
color: white;
border-bottom-color: #c9aa71;
}
}
&__games-grid {
list-style: none;
margin: 0;

View File

@@ -16,7 +16,8 @@ import "./profile-content.scss";
const GAME_STATS_ANIMATION_DURATION_IN_MS = 3500;
export function ProfileContent() {
const { userProfile, isMe, userStats } = useContext(userProfileContext);
const { userProfile, isMe, userStats, libraryGames, pinnedGames } =
useContext(userProfileContext);
const [statsIndex, setStatsIndex] = useState(0);
const [isAnimationRunning, setIsAnimationRunning] = useState(true);
const statsAnimation = useRef(-1);
@@ -79,7 +80,8 @@ export function ProfileContent() {
return <LockedProfile />;
}
const hasGames = userProfile?.libraryGames.length > 0;
const hasGames = libraryGames.length > 0;
const hasPinnedGames = pinnedGames.length > 0;
const shouldShowRightContent = hasGames || userProfile.friends.length > 0;
@@ -98,16 +100,36 @@ export function ProfileContent() {
{hasGames && (
<>
{hasPinnedGames && (
<div style={{ marginBottom: "2rem" }}>
<div className="profile-content__section-header">
<h2>{t("pinned")}</h2>
<span>{pinnedGames.length}</span>
</div>
<ul className="profile-content__games-grid">
{pinnedGames?.map((game) => (
<UserLibraryGameCard
game={game}
key={game.objectId}
statIndex={statsIndex}
onMouseEnter={handleOnMouseEnterGameCard}
onMouseLeave={handleOnMouseLeaveGameCard}
/>
))}
</ul>
</div>
)}
<div className="profile-content__section-header">
<h2>{t("library")}</h2>
{userStats && (
<span>{numberFormatter.format(userStats.libraryCount)}</span>
)}
</div>
<ul className="profile-content__games-grid">
{userProfile?.libraryGames?.map((game) => (
{libraryGames?.map((game) => (
<UserLibraryGameCard
game={game}
key={game.objectId}
@@ -139,6 +161,8 @@ export function ProfileContent() {
numberFormatter,
t,
statsIndex,
libraryGames,
pinnedGames,
]);
return (

View File

@@ -65,6 +65,20 @@
padding: 8px;
}
&__favorite-icon {
position: absolute;
top: 8px;
right: 8px;
color: #ff6b6b;
background-color: rgba(0, 0, 0, 0.7);
border-radius: 50%;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
z-index: 2;
}
&__playtime {
background-color: globals.$background-color;
color: globals.$muted-color;

View File

@@ -9,7 +9,12 @@ import {
formatDownloadProgress,
} from "@renderer/helpers";
import { userProfileContext } from "@renderer/context";
import { ClockIcon, TrophyIcon, AlertFillIcon } from "@primer/octicons-react";
import {
ClockIcon,
TrophyIcon,
AlertFillIcon,
HeartFillIcon,
} from "@primer/octicons-react";
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
import { Tooltip } from "react-tooltip";
import { useTranslation } from "react-i18next";
@@ -98,6 +103,11 @@ export function UserLibraryGameCard({
onClick={() => navigate(buildUserGameDetailsPath(game))}
>
<div className="user-library-game__overlay">
{game.isFavorite && (
<div className="user-library-game__favorite-icon">
<HeartFillIcon size={14} />
</div>
)}
<small
className="user-library-game__playtime"
data-tooltip-place="top"

View File

@@ -71,8 +71,17 @@ export type UserGame = {
achievementCount: number;
achievementsPointsEarnedSum: number;
hasManuallyUpdatedPlaytime: boolean;
isFavorite: boolean;
isPinned: boolean;
pinnedDate?: Date | null;
} & ShopAssets;
export interface UserLibraryResponse {
totalCount: number;
library: UserGame[];
pinnedGames: UserGame[];
}
export interface GameRunning {
id: string;
title: string;

View File

@@ -44,6 +44,8 @@ export interface Game {
executablePath?: string | null;
launchOptions?: string | null;
favorite?: boolean;
pinned?: boolean;
pinnedDate?: Date | null;
automaticCloudSync?: boolean;
hasManuallyUpdatedPlaytime?: boolean;
}