diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 9f8de8f8..b5162431 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -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", diff --git a/src/main/events/index.ts b/src/main/events/index.ts index 9765b517..00b387d2 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -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"; diff --git a/src/main/events/library/add-game-to-pinned.ts b/src/main/events/library/add-game-to-pinned.ts new file mode 100644 index 00000000..82b62d7b --- /dev/null +++ b/src/main/events/library/add-game-to-pinned.ts @@ -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); diff --git a/src/main/events/library/remove-game-from-pinned.ts b/src/main/events/library/remove-game-from-pinned.ts new file mode 100644 index 00000000..658b9d6d --- /dev/null +++ b/src/main/events/library/remove-game-from-pinned.ts @@ -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); diff --git a/src/main/events/user/get-user-library.ts b/src/main/events/user/get-user-library.ts new file mode 100644 index 00000000..8a715a49 --- /dev/null +++ b/src/main/events/user/get-user-library.ts @@ -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 => { + 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(url).catch(() => null); +}; + +registerEvent("getUserLibrary", getUserLibrary); diff --git a/src/main/services/library-sync/merge-with-remote-games.ts b/src/main/services/library-sync/merge-with-remote-games.ts index a35414ac..0d5d92f8 100644 --- a/src/main/services/library-sync/merge-with-remote-games.ts +++ b/src/main/services/library-sync/merge-with-remote-games.ts @@ -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, }); } diff --git a/src/main/services/library-sync/upload-games-batch.ts b/src/main/services/library-sync/upload-games-batch.ts index 837fb48a..beab164f 100644 --- a/src/main/services/library-sync/upload-games-batch.ts +++ b/src/main/services/library-sync/upload-games-batch.ts @@ -27,6 +27,7 @@ export const uploadGamesBatch = async () => { shop: game.shop, lastTimePlayed: game.lastTimePlayed, isFavorite: game.favorite, + isPinned: game.pinned ?? false, }; }) ).catch(() => {}); diff --git a/src/preload/index.ts b/src/preload/index.ts index d29417b0..b32fd6b0 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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) => diff --git a/src/renderer/src/context/user-profile/user-profile.context.tsx b/src/renderer/src/context/user-profile/user-profile.context.tsx index 9b9d16b4..3b10b3e5 100644 --- a/src/renderer/src/context/user-profile/user-profile.context.tsx +++ b/src/renderer/src/context/user-profile/user-profile.context.tsx @@ -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>; backgroundImage: string; badges: Badge[]; + libraryGames: UserGame[]; + pinnedGames: UserGame[]; } export const DEFAULT_USER_PROFILE_BACKGROUND = "#151515B3"; @@ -30,6 +32,8 @@ export const userProfileContext = createContext({ setSelectedBackgroundImage: () => {}, backgroundImage: "", badges: [], + libraryGames: [], + pinnedGames: [], }); const { Provider } = userProfileContext; @@ -49,6 +53,8 @@ export function UserProfileContextProvider({ const [userStats, setUserStats] = useState(null); const [userProfile, setUserProfile] = useState(null); + const [libraryGames, setLibraryGames] = useState([]); + const [pinnedGames, setPinnedGames] = useState([]); const [badges, setBadges] = useState([]); 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} diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index 0744884c..115841d4 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -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; + addGameToPinned: (shop: GameShop, objectId: string) => Promise; + removeGameFromPinned: (shop: GameShop, objectId: string) => Promise; updateLaunchOptions: ( shop: GameShop, objectId: string, @@ -287,6 +290,11 @@ declare global { /* User */ getUser: (userId: string) => Promise; + getUserLibrary: ( + userId: string, + take?: number, + skip?: number + ) => Promise; blockUser: (userId: string) => Promise; unblockUser: (userId: string) => Promise; getUserFriends: ( diff --git a/src/renderer/src/pages/game-details/hero/hero-panel-actions.tsx b/src/renderer/src/pages/game-details/hero/hero-panel-actions.tsx index a3b75d2e..bdc8cf83 100644 --- a/src/renderer/src/pages/game-details/hero/hero-panel-actions.tsx +++ b/src/renderer/src/pages/game-details/hero/hero-panel-actions.tsx @@ -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 ? : } + {userDetails && ( + + )} +