diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 1ed6bff8..66e0fee9 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -221,6 +221,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", @@ -462,6 +464,10 @@ "last_time_played": "Last played {{period}}", "activity": "Recent Activity", "library": "Library", + "pinned": "Pinned", + "achievements_earned": "Achievements earned", + "played_recently": "Played recently", + "playtime": "Playtime", "total_play_time": "Total playtime", "manual_playtime_tooltip": "This playtime has been manually updated", "no_recent_activity_title": "Hmmm… nothing here", @@ -538,7 +544,9 @@ "show_achievements_on_profile": "Show your achievements on your profile", "show_points_on_profile": "Show your earned points on your profile", "error_adding_friend": "Could not send friend request. Please check friend code", - "friend_code_length_error": "Friend code must have 8 characters" + "friend_code_length_error": "Friend code must have 8 characters", + "game_removed_from_pinned": "Game removed from pinned", + "game_added_to_pinned": "Game added to pinned" }, "achievement": { "achievement_unlocked": "Achievement unlocked", diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index 97d62e1a..b854d5a5 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -10,7 +10,8 @@ "hot": "Сейчас популярно", "start_typing": "Начинаю вводить текст...", "weekly": "📅 Лучшие игры недели", - "achievements": "🏆 Игры с достижениями" + "achievements": "🏆 Игры с достижениями", + "already_in_library": "Уже в библиотеке" }, "sidebar": { "catalogue": "Каталог", @@ -219,7 +220,23 @@ "invalid_wine_prefix_path": "Недопустимый путь префикса Wine", "invalid_wine_prefix_path_description": "Путь к префиксу Wine недействителен. Пожалуйста, проверьте путь и попробуйте снова.", "missing_wine_prefix": "Префикс Wine необходим для создания резервной копии в Linux", - "download_error_not_cached_on_hydra": "Эта загрузка недоступна на Nimbus." + "download_error_not_cached_on_hydra": "Эта загрузка недоступна на Nimbus.", + "update_playtime_success": "Время игры успешно обновлено", + "update_playtime_error": "Не удалось обновить время игры", + "manual_playtime_warning": "Ваши часы будут отмечены как обновленные вручную. Это действие нельзя отменить.", + "artifact_renamed": "Резервная копия успешно переименована", + "rename_artifact": "Переименовать резервную копию", + "rename_artifact_description": "Переименуйте резервную копию, присвоив ей более описательное имя.", + "artifact_name_label": "Название резервной копии", + "artifact_name_placeholder": "Введите название для резервной копии", + "max_length_field": "Это поле должно содержать менее {{length}} символов", + "freeze_backup": "Закрепить, чтобы она не была перезаписана автоматическими резервными копиями", + "unfreeze_backup": "Открепить", + "backup_frozen": "Резервная копия закреплена", + "backup_unfrozen": "Резервная копия откреплена", + "backup_freeze_failed": "Не удалось закрепить резервную копию", + "backup_freeze_failed_description": "Вы должны оставить как минимум один свободный слот для автоматических резервных копий", + "manual_playtime_tooltip": "Это время игры было обновлено вручную" }, "activation": { "title": "Активировать Hydra", @@ -395,7 +412,8 @@ "hidden": "Скрытый", "test_notification": "Тестовое уведомление", "notification_preview": "Предварительный просмотр уведомления о достижении", - "enable_friend_start_game_notifications": "Когда друг начинает играть в игру" + "enable_friend_start_game_notifications": "Когда друг начинает играть в игру", + "enable_steam_achievements": "Включить поиск достижений Steam" }, "notifications": { "download_complete": "Загрузка завершена", @@ -408,12 +426,12 @@ "notification_achievement_unlocked_title": "Достижение разблокировано для {{game}}", "notification_achievement_unlocked_body": "были разблокированы {{achievement}} и другие {{count}}", "new_friend_request_title": "Новый запрос на добавление в друзья", - "new_friend_request_description": "Вы получили новый запрос на добавление в друзья", "extraction_complete": "Распаковка завершена", "game_extracted": "{{title}} успешно распакован", "friend_started_playing_game": "{{displayName}} начал играть в игру", "test_achievement_notification_title": "Это тестовое уведомление", - "test_achievement_notification_description": "Довольно круто, да?" + "test_achievement_notification_description": "Довольно круто, да?", + "new_friend_request_description": "{{displayName}} отправил вам запрос в друзья" }, "system_tray": { "open": "Открыть Hydra", @@ -515,7 +533,9 @@ "achievements_unlocked": "Достижения разблокированы", "earned_points": "Заработано очков:", "show_achievements_on_profile": "Покажите свои достижения в профиле", - "show_points_on_profile": "Показывать заработанные очки в своем профиле" + "show_points_on_profile": "Показывать заработанные очки в своем профиле", + "error_adding_friend": "Не удалось отправить запрос в друзья. Пожалуйста, проверьте код друга", + "friend_code_length_error": "Код друга должен содержать 8 символов" }, "achievement": { "achievement_unlocked": "Достижение разблокировано", diff --git a/src/main/events/index.ts b/src/main/events/index.ts index 9765b517..6bd74b69 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -16,6 +16,7 @@ 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/toggle-game-pin"; import "./library/create-game-shortcut"; import "./library/close-game"; import "./library/delete-game-folder"; @@ -64,6 +65,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/toggle-game-pin.ts b/src/main/events/library/toggle-game-pin.ts new file mode 100644 index 00000000..addedddd --- /dev/null +++ b/src/main/events/library/toggle-game-pin.ts @@ -0,0 +1,43 @@ +import { registerEvent } from "../register-event"; +import { gamesSublevel, levelKeys } from "@main/level"; +import { HydraApi, logger } from "@main/services"; +import type { GameShop, UserGame } from "@types"; + +const toggleGamePin = async ( + _event: Electron.IpcMainInvokeEvent, + shop: GameShop, + objectId: string, + pin: boolean +) => { + try { + const gameKey = levelKeys.game(shop, objectId); + + const game = await gamesSublevel.get(gameKey); + if (!game) return; + + if (pin) { + const response = await HydraApi.put( + `/profile/games/${shop}/${objectId}/pin` + ); + + await gamesSublevel.put(gameKey, { + ...game, + isPinned: pin, + pinnedDate: new Date(response.pinnedDate!), + }); + } else { + await HydraApi.put(`/profile/games/${shop}/${objectId}/unpin`); + + await gamesSublevel.put(gameKey, { + ...game, + isPinned: pin, + pinnedDate: null, + }); + } + } catch (error) { + logger.error("Failed to update game pinned status", error); + throw new Error(`Failed to update game pinned status: ${error}`); + } +}; + +registerEvent("toggleGamePin", toggleGamePin); 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..f3c3eed5 --- /dev/null +++ b/src/main/events/user/get-user-library.ts @@ -0,0 +1,28 @@ +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, + sortBy?: string +): Promise => { + const params = new URLSearchParams(); + + params.append("take", take.toString()); + params.append("skip", skip.toString()); + + if (sortBy) { + params.append("sortBy", sortBy); + } + + 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..152e1138 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, + isPinned: game.isPinned ?? localGame.isPinned, }); } else { await gamesSublevel.put(gameKey, { @@ -49,6 +51,7 @@ export const mergeWithRemoteGames = async () => { hasManuallyUpdatedPlaytime: game.hasManuallyUpdatedPlaytime, isDeleted: false, favorite: game.isFavorite ?? false, + isPinned: 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..d4febfea 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.isPinned ?? false, }; }) ).catch(() => {}); diff --git a/src/preload/index.ts b/src/preload/index.ts index d29417b0..ca275c91 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -143,6 +143,8 @@ contextBridge.exposeInMainWorld("electron", { ipcRenderer.invoke("addGameToFavorites", shop, objectId), removeGameFromFavorites: (shop: GameShop, objectId: string) => ipcRenderer.invoke("removeGameFromFavorites", shop, objectId), + toggleGamePin: (shop: GameShop, objectId: string, pinned: boolean) => + ipcRenderer.invoke("toggleGamePin", shop, objectId, pinned), updateLaunchOptions: ( shop: GameShop, objectId: string, @@ -366,6 +368,12 @@ contextBridge.exposeInMainWorld("electron", { /* User */ getUser: (userId: string) => ipcRenderer.invoke("getUser", userId), + getUserLibrary: ( + userId: string, + take?: number, + skip?: number, + sortBy?: string + ) => ipcRenderer.invoke("getUserLibrary", userId, take, skip, sortBy), 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..2750442a 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"; @@ -14,9 +14,12 @@ export interface UserProfileContext { isMe: boolean; userStats: UserStats | null; getUserProfile: () => Promise; + getUserLibraryGames: (sortBy?: string) => Promise; setSelectedBackgroundImage: React.Dispatch>; backgroundImage: string; badges: Badge[]; + libraryGames: UserGame[]; + pinnedGames: UserGame[]; } export const DEFAULT_USER_PROFILE_BACKGROUND = "#151515B3"; @@ -27,9 +30,12 @@ export const userProfileContext = createContext({ isMe: false, userStats: null, getUserProfile: async () => {}, + getUserLibraryGames: async (_sortBy?: string) => {}, setSelectedBackgroundImage: () => {}, backgroundImage: "", badges: [], + libraryGames: [], + pinnedGames: [], }); const { Provider } = userProfileContext; @@ -49,6 +55,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 +93,34 @@ export function UserProfileContextProvider({ }); }, [userId]); + const getUserLibraryGames = useCallback( + async (sortBy?: string) => { + try { + const response = await window.electron.getUserLibrary( + userId, + 12, + 0, + sortBy + ); + + 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 +136,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 +145,8 @@ export function UserProfileContextProvider({ useEffect(() => { setUserProfile(null); + setLibraryGames([]); + setPinnedGames([]); setHeroBackground(DEFAULT_USER_PROFILE_BACKGROUND); getUserProfile(); @@ -124,10 +160,13 @@ export function UserProfileContextProvider({ heroBackground, isMe, getUserProfile, + getUserLibraryGames, setSelectedBackgroundImage, 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..87b2d63d 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,11 @@ declare global { shop: GameShop, objectId: string ) => Promise; + toggleGamePin: ( + shop: GameShop, + objectId: string, + pinned: boolean + ) => Promise; updateLaunchOptions: ( shop: GameShop, objectId: string, @@ -287,6 +293,12 @@ declare global { /* User */ getUser: (userId: string) => Promise; + getUserLibrary: ( + userId: string, + take?: number, + skip?: number, + sortBy?: string + ) => Promise; blockUser: (userId: string) => Promise; unblockUser: (userId: string) => Promise; getUserFriends: ( diff --git a/src/renderer/src/hooks/use-section-collapse.ts b/src/renderer/src/hooks/use-section-collapse.ts new file mode 100644 index 00000000..7cd22224 --- /dev/null +++ b/src/renderer/src/hooks/use-section-collapse.ts @@ -0,0 +1,27 @@ +import { useState, useCallback } from "react"; + +interface SectionCollapseState { + pinned: boolean; + library: boolean; +} + +export function useSectionCollapse() { + const [collapseState, setCollapseState] = useState({ + pinned: false, + library: false, + }); + + const toggleSection = useCallback((section: keyof SectionCollapseState) => { + setCollapseState((prevState) => ({ + ...prevState, + [section]: !prevState[section], + })); + }, []); + + return { + collapseState, + toggleSection, + isPinnedCollapsed: collapseState.pinned, + isLibraryCollapsed: collapseState.library, + }; +} 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 8b86af79..010ed791 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"; @@ -20,6 +27,7 @@ export function HeroPanelActions() { useState(false); const { isGameDeleting } = useDownload(); + const { userDetails } = useUserDetails(); const { game, @@ -110,6 +118,29 @@ export function HeroPanelActions() { } }; + const toggleGamePinned = async () => { + setToggleLibraryGameDisabled(true); + + try { + if (game?.isPinned && objectId) { + await window.electron.toggleGamePin(shop, objectId, false).then(() => { + showSuccessToast(t("game_removed_from_pinned")); + }); + } else { + if (!objectId) return; + + await window.electron.toggleGamePin(shop, objectId, true).then(() => { + showSuccessToast(t("game_added_to_pinned")); + }); + } + + updateLibrary(); + updateGame(); + } finally { + setToggleLibraryGameDisabled(false); + } + }; + const openGame = async () => { if (game) { if (game.executablePath) { @@ -226,6 +257,17 @@ export function HeroPanelActions() { {game.favorite ? : } + {userDetails && ( + + )} + +

{t("pinned")}

+ + {pinnedGames.length} + + + - {userStats && ( - {numberFormatter.format(userStats.libraryCount)} - )} - + + {!isPinnedCollapsed && ( + + + {shouldAnimatePinned ? ( + + {pinnedGames?.map((game, index) => ( + + + + ))} + + ) : ( + pinnedGames?.map((game) => ( +
  • + +
  • + )) + )} +
    +
    + )} +
    + + )} -
      - {userProfile?.libraryGames?.map((game) => ( - - ))} -
    - + {hasGames && ( +
    +
    +
    +

    {t("library")}

    + {userStats && ( + + {numberFormatter.format(userStats.libraryCount)} + + )} +
    +
    + + + {shouldAnimateLibrary ? ( + + {libraryGames?.map((game, index) => ( + + + + ))} + + ) : ( + libraryGames?.map((game) => ( +
  • + +
  • + )) + )} +
    +
    + )} + )} @@ -139,6 +334,13 @@ export function ProfileContent() { numberFormatter, t, statsIndex, + libraryGames, + pinnedGames, + isPinnedCollapsed, + toggleSection, + shouldAnimateLibrary, + shouldAnimatePinned, + sortBy, ]); return ( diff --git a/src/renderer/src/pages/profile/profile-content/user-library-game-card.scss b/src/renderer/src/pages/profile/profile-content/user-library-game-card.scss index ee9e3160..ab1f3456 100644 --- a/src/renderer/src/pages/profile/profile-content/user-library-game-card.scss +++ b/src/renderer/src/pages/profile/profile-content/user-library-game-card.scss @@ -65,6 +65,47 @@ padding: 8px; } + &__actions-container { + position: absolute; + top: 8px; + right: 8px; + display: flex; + gap: 6px; + z-index: 2; + } + + &__favorite-icon { + color: white; + background-color: rgba(0, 0, 0, 0.7); + border-radius: 50%; + padding: 6px; + display: flex; + align-items: center; + justify-content: center; + } + + &__pin-button { + color: white; + background-color: rgba(0, 0, 0, 0.7); + border: none; + border-radius: 50%; + padding: 6px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: background-color 0.2s ease; + + &:hover { + background-color: rgba(0, 0, 0, 0.9); + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } + } + &__playtime { background-color: globals.$background-color; color: globals.$muted-color; diff --git a/src/renderer/src/pages/profile/profile-content/user-library-game-card.tsx b/src/renderer/src/pages/profile/profile-content/user-library-game-card.tsx index e47093b4..860c6758 100644 --- a/src/renderer/src/pages/profile/profile-content/user-library-game-card.tsx +++ b/src/renderer/src/pages/profile/profile-content/user-library-game-card.tsx @@ -1,6 +1,6 @@ import { UserGame } from "@types"; import HydraIcon from "@renderer/assets/icons/hydra.svg?react"; -import { useFormat } from "@renderer/hooks"; +import { useFormat, useToast } from "@renderer/hooks"; import { useNavigate } from "react-router-dom"; import { useCallback, useContext, useState } from "react"; import { @@ -9,7 +9,14 @@ import { formatDownloadProgress, } from "@renderer/helpers"; import { userProfileContext } from "@renderer/context"; -import { ClockIcon, TrophyIcon, AlertFillIcon } from "@primer/octicons-react"; +import { + ClockIcon, + TrophyIcon, + AlertFillIcon, + HeartFillIcon, + PinIcon, + PinSlashIcon, +} from "@primer/octicons-react"; import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants"; import { Tooltip } from "react-tooltip"; import { useTranslation } from "react-i18next"; @@ -28,11 +35,14 @@ export function UserLibraryGameCard({ onMouseEnter, onMouseLeave, }: UserLibraryGameCardProps) { - const { userProfile } = useContext(userProfileContext); + const { userProfile, isMe, getUserLibraryGames } = + useContext(userProfileContext); const { t } = useTranslation("user_profile"); const { numberFormatter } = useFormat(); + const { showSuccessToast } = useToast(); const navigate = useNavigate(); const [isTooltipHovered, setIsTooltipHovered] = useState(false); + const [isPinning, setIsPinning] = useState(false); const getStatsItemCount = useCallback(() => { let statsCount = 1; @@ -84,6 +94,28 @@ export function UserLibraryGameCard({ [numberFormatter, t] ); + const toggleGamePinned = async () => { + setIsPinning(true); + + try { + await window.electron.toggleGamePin( + game.shop, + game.objectId, + !game.isPinned + ); + + await getUserLibraryGames(); + + if (game.isPinned) { + showSuccessToast(t("game_removed_from_pinned")); + } else { + showSuccessToast(t("game_added_to_pinned")); + } + } finally { + setIsPinning(false); + } + }; + return ( <>
  • navigate(buildUserGameDetailsPath(game))} >
    + {(game.isFavorite || isMe) && ( +
    + {game.isFavorite && ( +
    + +
    + )} + {isMe && ( + + )} +
    + )}