diff --git a/package.json b/package.json index 3186535f..e21c962a 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,8 @@ "diskusage": "^1.2.0", "electron-log": "^5.2.4", "electron-updater": "^6.6.2", + "embla-carousel-autoplay": "^8.6.0", + "embla-carousel-react": "^8.6.0", "file-type": "^20.5.0", "framer-motion": "^12.15.0", "i18next": "^23.11.2", @@ -63,6 +65,8 @@ "lodash-es": "^4.17.21", "parse-torrent": "^11.0.18", "rc-virtual-list": "^3.18.3", + "react-dnd": "^16.0.1", + "react-dnd-html5-backend": "^16.0.1", "react-hook-form": "^7.53.0", "react-i18next": "^14.1.0", "react-loading-skeleton": "^3.4.0", diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json old mode 100644 new mode 100755 index 96fc573b..b1a9c61b --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -4,7 +4,6 @@ "successfully_signed_in": "Successfully signed in" }, "home": { - "featured": "Featured", "surprise_me": "Surprise me", "no_results": "No results found", "start_typing": "Starting typing to search...", @@ -252,6 +251,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", @@ -270,7 +271,20 @@ "backup_frozen": "Backup pinned", "backup_unfrozen": "Backup unpinned", "backup_freeze_failed": "Failed to freeze backup", - "backup_freeze_failed_description": "You must leave at least one free slot for automatic backups" + "backup_freeze_failed_description": "You must leave at least one free slot for automatic backups", + "game_details": "Game Details", + "currency_symbol": "$", + "currency_country": "us", + "prices": "Prices", + "no_prices_found": "No prices found", + "view_all_prices": "Click to view all prices", + "retail_price": "Retail price", + "keyshop_price": "Keyshop price", + "historical_retail": "Historical retail", + "historical_keyshop": "Historical keyshop", + "language": "Language", + "caption": "Caption", + "audio": "Audio" }, "activation": { "title": "Activate Hydra", @@ -493,6 +507,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", @@ -569,7 +587,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/pt-BR/translation.json b/src/locales/pt-BR/translation.json old mode 100644 new mode 100755 index 22f5b533..7f7f8cc1 --- a/src/locales/pt-BR/translation.json +++ b/src/locales/pt-BR/translation.json @@ -4,7 +4,6 @@ "successfully_signed_in": "Autenticado com sucesso" }, "home": { - "featured": "Destaques", "hot": "Populares", "weekly": "📅 Mais baixados da semana", "achievements": "🏆 Pra platinar", @@ -207,7 +206,20 @@ "backup_frozen": "Backup fixado", "backup_unfrozen": "Backup removido dos fixados", "backup_freeze_failed": "Falha ao fixar backup", - "backup_freeze_failed_description": "Você deve deixar pelo menos um espaço livre para backups automáticos" + "backup_freeze_failed_description": "Você deve deixar pelo menos um espaço livre para backups automáticos", + "game_details": "Detalhes do Jogo", + "currency_symbol": "R$", + "currency_country": "br", + "prices": "Preços", + "no_prices_found": "Nenhum preço encontrado", + "view_all_prices": "Clique para ver todos os preços", + "retail_price": "Preço de lojas oficiais", + "keyshop_price": "Preço em keyshops", + "historical_retail": "Preço histórico de lojas oficiais", + "historical_keyshop": "Preço histórico em keyshops", + "language": "Idioma", + "caption": "Legenda", + "audio": "Áudio" }, "activation": { "title": "Ativação", diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index b256f90c..58235989 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": "Каталог", @@ -249,7 +250,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", @@ -425,7 +442,8 @@ "hidden": "Скрытый", "test_notification": "Тестовое уведомление", "notification_preview": "Предварительный просмотр уведомления о достижении", - "enable_friend_start_game_notifications": "Когда друг начинает играть в игру" + "enable_friend_start_game_notifications": "Когда друг начинает играть в игру", + "enable_steam_achievements": "Включить поиск достижений Steam" }, "notifications": { "download_complete": "Загрузка завершена", @@ -438,12 +456,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", @@ -545,7 +563,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 2bd402e7..d4c461f8 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -19,6 +19,7 @@ import "./library/update-custom-game"; import "./library/update-game-custom-assets"; 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"; @@ -71,6 +72,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 3e1398f0..b5b2d551 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, { @@ -51,6 +53,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 653b5f40..f0af90ba 100644 --- a/src/main/services/library-sync/upload-games-batch.ts +++ b/src/main/services/library-sync/upload-games-batch.ts @@ -28,6 +28,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 f90e3f58..e536f8c7 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -201,6 +201,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, @@ -424,6 +426,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/app.scss b/src/renderer/src/app.scss index 18d46dd4..4c5374e8 100644 --- a/src/renderer/src/app.scss +++ b/src/renderer/src/app.scss @@ -10,16 +10,16 @@ } ::-webkit-scrollbar-track { - background-color: rgba(255, 255, 255, 0.03); + background-color: rgba(0, 0, 0, 0.2); } ::-webkit-scrollbar-thumb { - background-color: rgba(255, 255, 255, 0.08); + background-color: rgba(255, 255, 255, 0.15); border-radius: 24px; } ::-webkit-scrollbar-thumb:hover { - background-color: rgba(255, 255, 255, 0.16); + background-color: rgba(255, 255, 255, 0.25); } html, diff --git a/src/renderer/src/components/badge/badge.scss b/src/renderer/src/components/badge/badge.scss index 69c43b3e..f90f8749 100644 --- a/src/renderer/src/components/badge/badge.scss +++ b/src/renderer/src/components/badge/badge.scss @@ -4,9 +4,14 @@ color: globals.$muted-color; font-size: 10px; padding: calc(globals.$spacing-unit / 2) globals.$spacing-unit; - border: solid 1px globals.$muted-color; - border-radius: 4px; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 6px; display: flex; gap: 4px; align-items: center; + background: rgba(255, 255, 255, 0.1); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + transition: all ease 0.2s; } diff --git a/src/renderer/src/components/hero/hero.scss b/src/renderer/src/components/hero/hero.scss index ea14c059..f9ec4d36 100644 --- a/src/renderer/src/components/hero/hero.scss +++ b/src/renderer/src/components/hero/hero.scss @@ -2,16 +2,36 @@ .hero { width: 100%; - height: 280px; - min-height: 280px; - max-height: 280px; - border-radius: 4px; + height: 180px; + min-height: 150px; + border-radius: 0; color: #dadbe1; overflow: hidden; box-shadow: 0px 0px 15px 0px #000000; cursor: pointer; border: solid 1px globals.$border-color; z-index: 1; + flex-shrink: 0; + + @media (min-width: 480px) { + height: 220px; + min-height: 200px; + } + + @media (min-width: 768px) { + height: 300px; + min-height: 300px; + } + + @media (min-width: 1024px) and (min-height: 800px) { + height: 400px; + min-height: 400px; + } + + @media (min-width: 1024px) and (max-height: 799px) { + height: 300px; + min-height: 250px; + } &__media { object-fit: cover; @@ -47,10 +67,42 @@ &__content { width: 100%; height: 100%; - padding: calc(globals.$spacing-unit * 4) calc(globals.$spacing-unit * 3); - gap: calc(globals.$spacing-unit * 2); + padding: calc(globals.$spacing-unit * 2) calc(globals.$spacing-unit * 2); + gap: calc(globals.$spacing-unit); display: flex; flex-direction: column; justify-content: flex-end; + + @media (min-width: 768px) { + padding: calc(globals.$spacing-unit * 3) calc(globals.$spacing-unit * 3); + gap: calc(globals.$spacing-unit * 1.5); + } + + @media (min-width: 1024px) { + padding: calc(globals.$spacing-unit * 4) calc(globals.$spacing-unit * 3); + gap: calc(globals.$spacing-unit * 2); + } + } + + &__logo { + max-width: 100%; + height: auto; + width: 120px; + + @media (min-width: 480px) { + width: 150px; + } + + @media (min-width: 768px) { + width: 200px; + } + + @media (min-width: 1024px) and (min-height: 800px) { + width: 250px; + } + + @media (min-width: 1024px) and (max-height: 799px) { + width: 200px; + } } } diff --git a/src/renderer/src/components/hero/hero.tsx b/src/renderer/src/components/hero/hero.tsx index f177c598..ce73d144 100644 --- a/src/renderer/src/components/hero/hero.tsx +++ b/src/renderer/src/components/hero/hero.tsx @@ -53,6 +53,7 @@ export function Hero() { width="250px" alt={game.description ?? ""} loading="eager" + className="hero__logo" />

{game.description}

diff --git a/src/renderer/src/context/game-details/game-details.context.tsx b/src/renderer/src/context/game-details/game-details.context.tsx index d15690a6..6fe01663 100644 --- a/src/renderer/src/context/game-details/game-details.context.tsx +++ b/src/renderer/src/context/game-details/game-details.context.tsx @@ -38,14 +38,12 @@ export const gameDetailsContext = createContext({ isGameRunning: false, isLoading: false, objectId: undefined, - gameColor: "", showRepacksModal: false, showGameOptionsModal: false, stats: null, achievements: null, hasNSFWContentBlocked: false, lastDownloadedOption: null, - setGameColor: () => {}, selectGameExecutable: async () => null, updateGame: async () => {}, setShowGameOptionsModal: () => {}, @@ -82,7 +80,6 @@ export function GameDetailsContextProvider({ const [stats, setStats] = useState(null); const [isLoading, setIsLoading] = useState(true); - const [gameColor, setGameColor] = useState(""); const [isGameRunning, setIsGameRunning] = useState(false); const [showRepacksModal, setShowRepacksModal] = useState(false); const [showGameOptionsModal, setShowGameOptionsModal] = useState(false); @@ -292,7 +289,6 @@ export function GameDetailsContextProvider({ isGameRunning, isLoading, objectId, - gameColor, showGameOptionsModal, showRepacksModal, stats, @@ -300,7 +296,6 @@ export function GameDetailsContextProvider({ hasNSFWContentBlocked, lastDownloadedOption, setHasNSFWContentBlocked, - setGameColor, selectGameExecutable, updateGame, setShowRepacksModal, diff --git a/src/renderer/src/context/game-details/game-details.context.types.ts b/src/renderer/src/context/game-details/game-details.context.types.ts index 99c7b293..302460b7 100644 --- a/src/renderer/src/context/game-details/game-details.context.types.ts +++ b/src/renderer/src/context/game-details/game-details.context.types.ts @@ -16,14 +16,12 @@ export interface GameDetailsContext { isGameRunning: boolean; isLoading: boolean; objectId: string | undefined; - gameColor: string; showRepacksModal: boolean; showGameOptionsModal: boolean; stats: GameStats | null; achievements: UserAchievement[] | null; hasNSFWContentBlocked: boolean; lastDownloadedOption: GameRepack | null; - setGameColor: React.Dispatch>; selectGameExecutable: () => Promise; updateGame: () => Promise; setShowRepacksModal: React.Dispatch>; 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 4594c85f..81d18940 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"; @@ -157,6 +158,11 @@ declare global { shop: GameShop, objectId: string ) => Promise; + toggleGamePin: ( + shop: GameShop, + objectId: string, + pinned: boolean + ) => Promise; updateLaunchOptions: ( shop: GameShop, objectId: string, @@ -320,6 +326,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/achievements/achievements-content.tsx b/src/renderer/src/pages/achievements/achievements-content.tsx index 477925c7..ab50f2f1 100644 --- a/src/renderer/src/pages/achievements/achievements-content.tsx +++ b/src/renderer/src/pages/achievements/achievements-content.tsx @@ -9,8 +9,6 @@ import { import { LockIcon, PersonIcon, TrophyIcon } from "@primer/octicons-react"; import { gameDetailsContext } from "@renderer/context"; import type { ComparedAchievements } from "@types"; -import { average } from "color.js"; -import Color from "color"; import { Link } from "@renderer/components"; import { ComparedAchievementList } from "./compared-achievement-list"; import { AchievementList } from "./achievement-list"; @@ -119,15 +117,8 @@ export function AchievementsContent({ const containerRef = useRef(null); const [isHeaderStuck, setIsHeaderStuck] = useState(false); - const { - gameTitle, - objectId, - shop, - shopDetails, - achievements, - gameColor, - setGameColor, - } = useContext(gameDetailsContext); + const { gameTitle, objectId, shop, shopDetails, achievements } = + useContext(gameDetailsContext); const dispatch = useAppDispatch(); @@ -136,22 +127,6 @@ export function AchievementsContent({ dispatch(setHeaderTitle(gameTitle)); }, [dispatch, gameTitle]); - const handleHeroLoad = async () => { - const output = await average( - shopDetails?.assets?.libraryHeroImageUrl ?? "", - { - amount: 1, - format: "hex", - } - ); - - const backgroundColor = output - ? (new Color(output).darken(0.7).toString() as string) - : ""; - - setGameColor(backgroundColor); - }; - const onScroll: React.UIEventHandler = (event) => { const heroHeight = heroRef.current?.clientHeight ?? 150; @@ -191,7 +166,6 @@ export function AchievementsContent({ src={shopDetails?.assets?.libraryHeroImageUrl ?? ""} className="achievements-content__achievements-list__image" alt={gameTitle} - onLoad={handleHeroLoad} />
-
+
(null); - const mediaContainerRef = useRef(null); - const { t } = useTranslation("game_details"); const hasScreenshots = shopDetails && shopDetails.screenshots?.length; - const hasMovies = shopDetails && shopDetails.movies?.length; - const mediaCount = useMemo(() => { - if (!shopDetails) return 0; + const [emblaRef, emblaApi] = useEmblaCarousel({ loop: false }); + const [selectedIndex, setSelectedIndex] = useState(0); - if (shopDetails.screenshots && shopDetails.movies) { - return shopDetails.screenshots.length + shopDetails.movies.length; - } else if (shopDetails.movies) { - return shopDetails.movies.length; - } else if (shopDetails.screenshots) { - return shopDetails.screenshots.length; - } + const scrollPrev = useCallback(() => { + if (emblaApi) emblaApi.scrollPrev(); + }, [emblaApi]); - return 0; - }, [shopDetails]); + const scrollNext = useCallback(() => { + if (emblaApi) emblaApi.scrollNext(); + }, [emblaApi]); - const [mediaIndex, setMediaIndex] = useState(0); - const [showArrows, setShowArrows] = useState(false); + const scrollTo = useCallback( + (index: number) => { + if (emblaApi) emblaApi.scrollTo(index); + }, + [emblaApi] + ); - const showNextImage = () => { - setMediaIndex((index: number) => { - if (index === mediaCount - 1) return 0; + const scrollToPreview = useCallback( + (index: number, event: React.MouseEvent) => { + scrollTo(index); - return index + 1; - }); - }; + const button = event.currentTarget; + const previewContainer = button.parentElement; - const showPrevImage = () => { - setMediaIndex((index: number) => { - if (index === 0) return mediaCount - 1; + if (previewContainer) { + const containerRect = previewContainer.getBoundingClientRect(); + const buttonRect = button.getBoundingClientRect(); - return index - 1; - }); - }; + const isOffScreenLeft = buttonRect.left < containerRect.left; + const isOffScreenRight = buttonRect.right > containerRect.right; - useEffect(() => { - setMediaIndex(0); - }, [shopDetails]); - - useEffect(() => { - if (hasMovies && mediaContainerRef.current) { - mediaContainerRef.current.childNodes.forEach((node, index) => { - if (node instanceof HTMLVideoElement) { - if (index !== mediaIndex) { - node.pause(); - } + if (isOffScreenLeft || isOffScreenRight) { + button.scrollIntoView({ + behavior: "smooth", + block: "nearest", + inline: "center", + }); } + } + }, + [scrollTo] + ); + + useEffect(() => { + if (!emblaApi) return; + + let isInitialLoad = true; + + const onSelect = () => { + const newIndex = emblaApi.selectedScrollSnap(); + setSelectedIndex(newIndex); + + if (!isInitialLoad) { + const videos = document.querySelectorAll(".gallery-slider__media"); + videos.forEach((video) => { + if (video instanceof HTMLVideoElement) { + video.pause(); + } + }); + } + + isInitialLoad = false; + }; + + emblaApi.on("select", onSelect); + onSelect(); + + return () => { + emblaApi.off("select", onSelect); + }; + }, [emblaApi]); + + const mediaItems = useMemo(() => { + const items: Array<{ + id: string; + type: "video" | "image"; + src?: string; + poster?: string; + videoSrc?: string; + alt: string; + }> = []; + + if (shopDetails?.movies) { + shopDetails.movies.forEach((video, index) => { + items.push({ + id: String(video.id), + type: "video", + poster: video.thumbnail, + videoSrc: video.mp4.max.startsWith("http://") + ? video.mp4.max.replace("http://", "https://") + : video.mp4.max, + alt: t("video", { number: String(index + 1) }), + }); }); } - }, [hasMovies, mediaContainerRef, mediaIndex]); - useEffect(() => { - if (scrollContainerRef.current) { - const container = scrollContainerRef.current; - const totalWidth = container.scrollWidth - container.clientWidth; - const itemWidth = totalWidth / (mediaCount - 1); - const scrollLeft = mediaIndex * itemWidth; - container.scrollLeft = scrollLeft; + if (shopDetails?.screenshots) { + shopDetails.screenshots.forEach((image, index) => { + items.push({ + id: String(image.id), + type: "image", + src: image.path_full, + alt: t("screenshot", { number: String(index + 1) }), + }); + }); } - }, [shopDetails, mediaIndex, mediaCount]); + + return items; + }, [shopDetails, t]); const previews = useMemo(() => { const screenshotPreviews = shopDetails?.screenshots?.map(({ id, path_thumbnail }) => ({ id, thumbnail: path_thumbnail, + type: "image" as const, })) ?? []; if (shopDetails?.movies) { const moviePreviews = shopDetails.movies.map(({ id, thumbnail }) => ({ id, thumbnail, + type: "video" as const, })); return [...moviePreviews, ...screenshotPreviews]; @@ -93,96 +147,87 @@ export function GallerySlider() { return screenshotPreviews; }, [shopDetails]); + if (!hasScreenshots) { + return null; + } + return ( - <> - {hasScreenshots && ( -
-
setShowArrows(true)} - onMouseLeave={() => setShowArrows(false)} - className="gallery-slider__animation-container" - ref={mediaContainerRef} - > - {shopDetails.movies && - shopDetails.movies.map((video) => ( +
+
+
+ {mediaItems.map((item) => ( +
+ {item.type === "video" ? ( - ))} - - {hasScreenshots && - shopDetails.screenshots?.map((image, i) => ( + ) : ( {t("screenshot", - ))} - - - - -
- -
- {previews.map((media, i) => ( - - ))} -
+ )} +
+ ))}
- )} - + + + + +
+ +
+ {previews.map((media, i) => ( + + ))} +
+
); } diff --git a/src/renderer/src/pages/game-details/game-details-content.tsx b/src/renderer/src/pages/game-details/game-details-content.tsx index 4ef45b6f..eeeb67c7 100644 --- a/src/renderer/src/pages/game-details/game-details-content.tsx +++ b/src/renderer/src/pages/game-details/game-details-content.tsx @@ -183,12 +183,10 @@ export function GameDetailsContent() { src={heroImage} className="game-details__hero-image" alt={game?.title} - onLoad={handleHeroLoad} />
diff --git a/src/renderer/src/pages/game-details/game-details.scss b/src/renderer/src/pages/game-details/game-details.scss index 46c6f9db..a5566a96 100644 --- a/src/renderer/src/pages/game-details/game-details.scss +++ b/src/renderer/src/pages/game-details/game-details.scss @@ -18,7 +18,6 @@ $hero-height: 300px; &__wrapper { display: flex; flex-direction: column; - overflow: hidden; width: 100%; height: 100%; transition: all ease 0.3s; @@ -101,8 +100,8 @@ $hero-height: 300px; &__hero-image { width: 100%; - height: $hero-height; - min-height: $hero-height; + height: calc($hero-height + 72px); + min-height: calc($hero-height + 72px); object-fit: cover; object-position: top; transition: all ease 0.2s; @@ -111,8 +110,8 @@ $hero-height: 300px; @media (min-width: 1250px) { object-position: center; - height: 350px; - min-height: 350px; + height: calc(350px + 72px); + min-height: calc(350px + 72px); } } @@ -148,7 +147,6 @@ $hero-height: 300px; height: 100%; display: flex; flex-direction: column; - overflow: auto; z-index: 1; } @@ -156,6 +154,7 @@ $hero-height: 300px; display: flex; width: 100%; flex: 1; + min-width: 0; background: linear-gradient( 0deg, globals.$background-color 50%, @@ -166,6 +165,8 @@ $hero-height: 300px; &__description-content { width: 100%; height: 100%; + min-width: 0; + flex: 1; } &__description { 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..307de108 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?.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) { @@ -198,6 +229,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/sort-options.scss b/src/renderer/src/pages/profile/profile-content/sort-options.scss new file mode 100644 index 00000000..e36f3727 --- /dev/null +++ b/src/renderer/src/pages/profile/profile-content/sort-options.scss @@ -0,0 +1,56 @@ +@use "../../../scss/globals.scss"; + +.sort-options { + &__container { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: calc(globals.$spacing-unit); + margin-bottom: calc(globals.$spacing-unit * 2); + } + + &__label { + color: rgba(255, 255, 255, 0.6); + font-size: 14px; + font-weight: 400; + } + + &__options { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit); + font-size: 14px; + } + + &__option { + background: none; + border: none; + color: rgba(255, 255, 255, 0.4); + cursor: pointer; + padding: 4px 0; + font-size: 14px; + font-weight: 300; + transition: all ease 0.2s; + display: flex; + align-items: center; + gap: 6px; + + &:hover:not(:disabled) { + color: rgba(255, 255, 255, 0.6); + } + + &.active { + color: rgba(255, 255, 255, 0.9); + font-weight: 500; + } + + span { + display: inline-block; + } + } + + &__separator { + color: rgba(255, 255, 255, 0.3); + font-size: 14px; + } +} diff --git a/src/renderer/src/pages/profile/profile-content/sort-options.tsx b/src/renderer/src/pages/profile/profile-content/sort-options.tsx new file mode 100644 index 00000000..53da8e40 --- /dev/null +++ b/src/renderer/src/pages/profile/profile-content/sort-options.tsx @@ -0,0 +1,45 @@ +import { TrophyIcon, ClockIcon, HistoryIcon } from "@primer/octicons-react"; +import { useTranslation } from "react-i18next"; +import "./sort-options.scss"; + +type SortOption = "playtime" | "achievementCount" | "playedRecently"; + +interface SortOptionsProps { + sortBy: SortOption; + onSortChange: (sortBy: SortOption) => void; +} + +export function SortOptions({ sortBy, onSortChange }: SortOptionsProps) { + const { t } = useTranslation("user_profile"); + + return ( +
    + Sort by: +
    + + | + + | + +
    +
    + ); +} 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..f072fdd5 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 @@ -7,10 +7,26 @@ position: relative; display: flex; transition: all ease 0.2s; + cursor: grab; &:hover { transform: scale(1.05); } + + &:active { + cursor: grabbing; + transform: scale(1.02); + } + + &[draggable="true"] { + cursor: grab; + + &:active { + cursor: grabbing; + opacity: 0.8; + transform: scale(1.02) rotate(2deg); + } + } } &__cover { @@ -65,15 +81,85 @@ padding: 8px; } + &__actions-container { + position: absolute; + top: 8px; + right: 8px; + display: flex; + gap: 6px; + z-index: 2; + } + + &__favorite-icon { + color: rgba(255, 255, 255, 0.8); + background: rgba(0, 0, 0, 0.4); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + border: solid 1px rgba(255, 255, 255, 0.15); + border-radius: 50%; + padding: 6px; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + transition: all ease 0.2s; + + &:hover { + background: rgba(0, 0, 0, 0.5); + border-color: rgba(255, 255, 255, 0.25); + transform: translateY(-1px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); + } + } + + &__pin-button { + color: rgba(255, 255, 255, 0.8); + background: rgba(0, 0, 0, 0.4); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + border: solid 1px rgba(255, 255, 255, 0.15); + border-radius: 50%; + padding: 6px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + transition: all ease 0.2s; + + &:hover { + background: rgba(0, 0, 0, 0.5); + border-color: rgba(255, 255, 255, 0.25); + transform: translateY(-1px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } + } + &__playtime { - background-color: globals.$background-color; - color: globals.$muted-color; - border: solid 1px globals.$border-color; + background: rgba(0, 0, 0, 0.4); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + color: rgba(255, 255, 255, 0.8); + border: solid 1px rgba(255, 255, 255, 0.15); border-radius: 4px; display: flex; align-items: center; gap: 4px; padding: 4px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + transition: all ease 0.2s; + + &:hover { + background: rgba(0, 0, 0, 0.5); + border-color: rgba(255, 255, 255, 0.25); + transform: translateY(-1px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); + } } &__manual-playtime { color: globals.$warning-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 && ( + + )} +
    + )}