mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-02-01 07:11:02 +00:00
Merge branch 'feat/context_menu-and-enhancement-actions' of https://github.com/caduHD4/hydra into feat/context_menu-and-enhancement-actions
This commit is contained in:
@@ -221,6 +221,8 @@
|
|||||||
"download_error_not_cached_on_hydra": "This download is not available on Nimbus.",
|
"download_error_not_cached_on_hydra": "This download is not available on Nimbus.",
|
||||||
"game_removed_from_favorites": "Game removed from favorites",
|
"game_removed_from_favorites": "Game removed from favorites",
|
||||||
"game_added_to_favorites": "Game added to 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",
|
"automatically_extract_downloaded_files": "Automatically extract downloaded files",
|
||||||
"create_start_menu_shortcut": "Create Start Menu shortcut",
|
"create_start_menu_shortcut": "Create Start Menu shortcut",
|
||||||
"invalid_wine_prefix_path": "Invalid Wine prefix path",
|
"invalid_wine_prefix_path": "Invalid Wine prefix path",
|
||||||
@@ -462,6 +464,10 @@
|
|||||||
"last_time_played": "Last played {{period}}",
|
"last_time_played": "Last played {{period}}",
|
||||||
"activity": "Recent Activity",
|
"activity": "Recent Activity",
|
||||||
"library": "Library",
|
"library": "Library",
|
||||||
|
"pinned": "Pinned",
|
||||||
|
"achievements_earned": "Achievements earned",
|
||||||
|
"played_recently": "Played recently",
|
||||||
|
"playtime": "Playtime",
|
||||||
"total_play_time": "Total playtime",
|
"total_play_time": "Total playtime",
|
||||||
"manual_playtime_tooltip": "This playtime has been manually updated",
|
"manual_playtime_tooltip": "This playtime has been manually updated",
|
||||||
"no_recent_activity_title": "Hmmm… nothing here",
|
"no_recent_activity_title": "Hmmm… nothing here",
|
||||||
@@ -538,7 +544,9 @@
|
|||||||
"show_achievements_on_profile": "Show your achievements on your profile",
|
"show_achievements_on_profile": "Show your achievements on your profile",
|
||||||
"show_points_on_profile": "Show your earned points 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",
|
"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": {
|
||||||
"achievement_unlocked": "Achievement unlocked",
|
"achievement_unlocked": "Achievement unlocked",
|
||||||
|
|||||||
@@ -10,7 +10,8 @@
|
|||||||
"hot": "Сейчас популярно",
|
"hot": "Сейчас популярно",
|
||||||
"start_typing": "Начинаю вводить текст...",
|
"start_typing": "Начинаю вводить текст...",
|
||||||
"weekly": "📅 Лучшие игры недели",
|
"weekly": "📅 Лучшие игры недели",
|
||||||
"achievements": "🏆 Игры с достижениями"
|
"achievements": "🏆 Игры с достижениями",
|
||||||
|
"already_in_library": "Уже в библиотеке"
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"catalogue": "Каталог",
|
"catalogue": "Каталог",
|
||||||
@@ -219,7 +220,23 @@
|
|||||||
"invalid_wine_prefix_path": "Недопустимый путь префикса Wine",
|
"invalid_wine_prefix_path": "Недопустимый путь префикса Wine",
|
||||||
"invalid_wine_prefix_path_description": "Путь к префиксу Wine недействителен. Пожалуйста, проверьте путь и попробуйте снова.",
|
"invalid_wine_prefix_path_description": "Путь к префиксу Wine недействителен. Пожалуйста, проверьте путь и попробуйте снова.",
|
||||||
"missing_wine_prefix": "Префикс Wine необходим для создания резервной копии в Linux",
|
"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": {
|
"activation": {
|
||||||
"title": "Активировать Hydra",
|
"title": "Активировать Hydra",
|
||||||
@@ -395,7 +412,8 @@
|
|||||||
"hidden": "Скрытый",
|
"hidden": "Скрытый",
|
||||||
"test_notification": "Тестовое уведомление",
|
"test_notification": "Тестовое уведомление",
|
||||||
"notification_preview": "Предварительный просмотр уведомления о достижении",
|
"notification_preview": "Предварительный просмотр уведомления о достижении",
|
||||||
"enable_friend_start_game_notifications": "Когда друг начинает играть в игру"
|
"enable_friend_start_game_notifications": "Когда друг начинает играть в игру",
|
||||||
|
"enable_steam_achievements": "Включить поиск достижений Steam"
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"download_complete": "Загрузка завершена",
|
"download_complete": "Загрузка завершена",
|
||||||
@@ -408,12 +426,12 @@
|
|||||||
"notification_achievement_unlocked_title": "Достижение разблокировано для {{game}}",
|
"notification_achievement_unlocked_title": "Достижение разблокировано для {{game}}",
|
||||||
"notification_achievement_unlocked_body": "были разблокированы {{achievement}} и другие {{count}}",
|
"notification_achievement_unlocked_body": "были разблокированы {{achievement}} и другие {{count}}",
|
||||||
"new_friend_request_title": "Новый запрос на добавление в друзья",
|
"new_friend_request_title": "Новый запрос на добавление в друзья",
|
||||||
"new_friend_request_description": "Вы получили новый запрос на добавление в друзья",
|
|
||||||
"extraction_complete": "Распаковка завершена",
|
"extraction_complete": "Распаковка завершена",
|
||||||
"game_extracted": "{{title}} успешно распакован",
|
"game_extracted": "{{title}} успешно распакован",
|
||||||
"friend_started_playing_game": "{{displayName}} начал играть в игру",
|
"friend_started_playing_game": "{{displayName}} начал играть в игру",
|
||||||
"test_achievement_notification_title": "Это тестовое уведомление",
|
"test_achievement_notification_title": "Это тестовое уведомление",
|
||||||
"test_achievement_notification_description": "Довольно круто, да?"
|
"test_achievement_notification_description": "Довольно круто, да?",
|
||||||
|
"new_friend_request_description": "{{displayName}} отправил вам запрос в друзья"
|
||||||
},
|
},
|
||||||
"system_tray": {
|
"system_tray": {
|
||||||
"open": "Открыть Hydra",
|
"open": "Открыть Hydra",
|
||||||
@@ -515,7 +533,9 @@
|
|||||||
"achievements_unlocked": "Достижения разблокированы",
|
"achievements_unlocked": "Достижения разблокированы",
|
||||||
"earned_points": "Заработано очков:",
|
"earned_points": "Заработано очков:",
|
||||||
"show_achievements_on_profile": "Покажите свои достижения в профиле",
|
"show_achievements_on_profile": "Покажите свои достижения в профиле",
|
||||||
"show_points_on_profile": "Показывать заработанные очки в своем профиле"
|
"show_points_on_profile": "Показывать заработанные очки в своем профиле",
|
||||||
|
"error_adding_friend": "Не удалось отправить запрос в друзья. Пожалуйста, проверьте код друга",
|
||||||
|
"friend_code_length_error": "Код друга должен содержать 8 символов"
|
||||||
},
|
},
|
||||||
"achievement": {
|
"achievement": {
|
||||||
"achievement_unlocked": "Достижение разблокировано",
|
"achievement_unlocked": "Достижение разблокировано",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import "./hardware/check-folder-write-permission";
|
|||||||
import "./library/add-game-to-library";
|
import "./library/add-game-to-library";
|
||||||
import "./library/add-game-to-favorites";
|
import "./library/add-game-to-favorites";
|
||||||
import "./library/remove-game-from-favorites";
|
import "./library/remove-game-from-favorites";
|
||||||
|
import "./library/toggle-game-pin";
|
||||||
import "./library/create-game-shortcut";
|
import "./library/create-game-shortcut";
|
||||||
import "./library/close-game";
|
import "./library/close-game";
|
||||||
import "./library/delete-game-folder";
|
import "./library/delete-game-folder";
|
||||||
@@ -64,6 +65,7 @@ import "./auth/sign-out";
|
|||||||
import "./auth/open-auth-window";
|
import "./auth/open-auth-window";
|
||||||
import "./auth/get-session-hash";
|
import "./auth/get-session-hash";
|
||||||
import "./user/get-user";
|
import "./user/get-user";
|
||||||
|
import "./user/get-user-library";
|
||||||
import "./user/get-blocked-users";
|
import "./user/get-blocked-users";
|
||||||
import "./user/block-user";
|
import "./user/block-user";
|
||||||
import "./user/unblock-user";
|
import "./user/unblock-user";
|
||||||
|
|||||||
43
src/main/events/library/toggle-game-pin.ts
Normal file
43
src/main/events/library/toggle-game-pin.ts
Normal file
@@ -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<UserGame>(
|
||||||
|
`/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);
|
||||||
28
src/main/events/user/get-user-library.ts
Normal file
28
src/main/events/user/get-user-library.ts
Normal file
@@ -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<UserLibraryResponse | null> => {
|
||||||
|
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<UserLibraryResponse>(url).catch(() => null);
|
||||||
|
};
|
||||||
|
|
||||||
|
registerEvent("getUserLibrary", getUserLibrary);
|
||||||
@@ -8,6 +8,7 @@ type ProfileGame = {
|
|||||||
playTimeInMilliseconds: number;
|
playTimeInMilliseconds: number;
|
||||||
hasManuallyUpdatedPlaytime: boolean;
|
hasManuallyUpdatedPlaytime: boolean;
|
||||||
isFavorite?: boolean;
|
isFavorite?: boolean;
|
||||||
|
isPinned?: boolean;
|
||||||
} & ShopAssets;
|
} & ShopAssets;
|
||||||
|
|
||||||
export const mergeWithRemoteGames = async () => {
|
export const mergeWithRemoteGames = async () => {
|
||||||
@@ -36,6 +37,7 @@ export const mergeWithRemoteGames = async () => {
|
|||||||
lastTimePlayed: updatedLastTimePlayed,
|
lastTimePlayed: updatedLastTimePlayed,
|
||||||
playTimeInMilliseconds: updatedPlayTime,
|
playTimeInMilliseconds: updatedPlayTime,
|
||||||
favorite: game.isFavorite ?? localGame.favorite,
|
favorite: game.isFavorite ?? localGame.favorite,
|
||||||
|
isPinned: game.isPinned ?? localGame.isPinned,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await gamesSublevel.put(gameKey, {
|
await gamesSublevel.put(gameKey, {
|
||||||
@@ -49,6 +51,7 @@ export const mergeWithRemoteGames = async () => {
|
|||||||
hasManuallyUpdatedPlaytime: game.hasManuallyUpdatedPlaytime,
|
hasManuallyUpdatedPlaytime: game.hasManuallyUpdatedPlaytime,
|
||||||
isDeleted: false,
|
isDeleted: false,
|
||||||
favorite: game.isFavorite ?? false,
|
favorite: game.isFavorite ?? false,
|
||||||
|
isPinned: game.isPinned ?? false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export const uploadGamesBatch = async () => {
|
|||||||
shop: game.shop,
|
shop: game.shop,
|
||||||
lastTimePlayed: game.lastTimePlayed,
|
lastTimePlayed: game.lastTimePlayed,
|
||||||
isFavorite: game.favorite,
|
isFavorite: game.favorite,
|
||||||
|
isPinned: game.isPinned ?? false,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
).catch(() => {});
|
).catch(() => {});
|
||||||
|
|||||||
@@ -143,6 +143,8 @@ contextBridge.exposeInMainWorld("electron", {
|
|||||||
ipcRenderer.invoke("addGameToFavorites", shop, objectId),
|
ipcRenderer.invoke("addGameToFavorites", shop, objectId),
|
||||||
removeGameFromFavorites: (shop: GameShop, objectId: string) =>
|
removeGameFromFavorites: (shop: GameShop, objectId: string) =>
|
||||||
ipcRenderer.invoke("removeGameFromFavorites", shop, objectId),
|
ipcRenderer.invoke("removeGameFromFavorites", shop, objectId),
|
||||||
|
toggleGamePin: (shop: GameShop, objectId: string, pinned: boolean) =>
|
||||||
|
ipcRenderer.invoke("toggleGamePin", shop, objectId, pinned),
|
||||||
updateLaunchOptions: (
|
updateLaunchOptions: (
|
||||||
shop: GameShop,
|
shop: GameShop,
|
||||||
objectId: string,
|
objectId: string,
|
||||||
@@ -366,6 +368,12 @@ contextBridge.exposeInMainWorld("electron", {
|
|||||||
|
|
||||||
/* User */
|
/* User */
|
||||||
getUser: (userId: string) => ipcRenderer.invoke("getUser", userId),
|
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),
|
blockUser: (userId: string) => ipcRenderer.invoke("blockUser", userId),
|
||||||
unblockUser: (userId: string) => ipcRenderer.invoke("unblockUser", userId),
|
unblockUser: (userId: string) => ipcRenderer.invoke("unblockUser", userId),
|
||||||
getUserFriends: (userId: string, take: number, skip: number) =>
|
getUserFriends: (userId: string, take: number, skip: number) =>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { darkenColor } from "@renderer/helpers";
|
import { darkenColor } from "@renderer/helpers";
|
||||||
import { useAppSelector, useToast } from "@renderer/hooks";
|
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 { average } from "color.js";
|
||||||
|
|
||||||
import { createContext, useCallback, useEffect, useState } from "react";
|
import { createContext, useCallback, useEffect, useState } from "react";
|
||||||
@@ -14,9 +14,12 @@ export interface UserProfileContext {
|
|||||||
isMe: boolean;
|
isMe: boolean;
|
||||||
userStats: UserStats | null;
|
userStats: UserStats | null;
|
||||||
getUserProfile: () => Promise<void>;
|
getUserProfile: () => Promise<void>;
|
||||||
|
getUserLibraryGames: (sortBy?: string) => Promise<void>;
|
||||||
setSelectedBackgroundImage: React.Dispatch<React.SetStateAction<string>>;
|
setSelectedBackgroundImage: React.Dispatch<React.SetStateAction<string>>;
|
||||||
backgroundImage: string;
|
backgroundImage: string;
|
||||||
badges: Badge[];
|
badges: Badge[];
|
||||||
|
libraryGames: UserGame[];
|
||||||
|
pinnedGames: UserGame[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_USER_PROFILE_BACKGROUND = "#151515B3";
|
export const DEFAULT_USER_PROFILE_BACKGROUND = "#151515B3";
|
||||||
@@ -27,9 +30,12 @@ export const userProfileContext = createContext<UserProfileContext>({
|
|||||||
isMe: false,
|
isMe: false,
|
||||||
userStats: null,
|
userStats: null,
|
||||||
getUserProfile: async () => {},
|
getUserProfile: async () => {},
|
||||||
|
getUserLibraryGames: async (_sortBy?: string) => {},
|
||||||
setSelectedBackgroundImage: () => {},
|
setSelectedBackgroundImage: () => {},
|
||||||
backgroundImage: "",
|
backgroundImage: "",
|
||||||
badges: [],
|
badges: [],
|
||||||
|
libraryGames: [],
|
||||||
|
pinnedGames: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
const { Provider } = userProfileContext;
|
const { Provider } = userProfileContext;
|
||||||
@@ -49,6 +55,8 @@ export function UserProfileContextProvider({
|
|||||||
const [userStats, setUserStats] = useState<UserStats | null>(null);
|
const [userStats, setUserStats] = useState<UserStats | null>(null);
|
||||||
|
|
||||||
const [userProfile, setUserProfile] = useState<UserProfile | 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 [badges, setBadges] = useState<Badge[]>([]);
|
||||||
const [heroBackground, setHeroBackground] = useState(
|
const [heroBackground, setHeroBackground] = useState(
|
||||||
DEFAULT_USER_PROFILE_BACKGROUND
|
DEFAULT_USER_PROFILE_BACKGROUND
|
||||||
@@ -85,8 +93,34 @@ export function UserProfileContextProvider({
|
|||||||
});
|
});
|
||||||
}, [userId]);
|
}, [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 () => {
|
const getUserProfile = useCallback(async () => {
|
||||||
getUserStats();
|
getUserStats();
|
||||||
|
getUserLibraryGames();
|
||||||
|
|
||||||
return window.electron.getUser(userId).then((userProfile) => {
|
return window.electron.getUser(userId).then((userProfile) => {
|
||||||
if (userProfile) {
|
if (userProfile) {
|
||||||
@@ -102,7 +136,7 @@ export function UserProfileContextProvider({
|
|||||||
navigate(-1);
|
navigate(-1);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [navigate, getUserStats, showErrorToast, userId, t]);
|
}, [navigate, getUserStats, getUserLibraryGames, showErrorToast, userId, t]);
|
||||||
|
|
||||||
const getBadges = useCallback(async () => {
|
const getBadges = useCallback(async () => {
|
||||||
const badges = await window.electron.getBadges();
|
const badges = await window.electron.getBadges();
|
||||||
@@ -111,6 +145,8 @@ export function UserProfileContextProvider({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setUserProfile(null);
|
setUserProfile(null);
|
||||||
|
setLibraryGames([]);
|
||||||
|
setPinnedGames([]);
|
||||||
setHeroBackground(DEFAULT_USER_PROFILE_BACKGROUND);
|
setHeroBackground(DEFAULT_USER_PROFILE_BACKGROUND);
|
||||||
|
|
||||||
getUserProfile();
|
getUserProfile();
|
||||||
@@ -124,10 +160,13 @@ export function UserProfileContextProvider({
|
|||||||
heroBackground,
|
heroBackground,
|
||||||
isMe,
|
isMe,
|
||||||
getUserProfile,
|
getUserProfile,
|
||||||
|
getUserLibraryGames,
|
||||||
setSelectedBackgroundImage,
|
setSelectedBackgroundImage,
|
||||||
backgroundImage: getBackgroundImageUrl(),
|
backgroundImage: getBackgroundImageUrl(),
|
||||||
userStats,
|
userStats,
|
||||||
badges,
|
badges,
|
||||||
|
libraryGames,
|
||||||
|
pinnedGames,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
12
src/renderer/src/declaration.d.ts
vendored
12
src/renderer/src/declaration.d.ts
vendored
@@ -37,6 +37,7 @@ import type {
|
|||||||
ShopDetailsWithAssets,
|
ShopDetailsWithAssets,
|
||||||
AchievementCustomNotificationPosition,
|
AchievementCustomNotificationPosition,
|
||||||
AchievementNotificationInfo,
|
AchievementNotificationInfo,
|
||||||
|
UserLibraryResponse,
|
||||||
} from "@types";
|
} from "@types";
|
||||||
import type { AxiosProgressEvent } from "axios";
|
import type { AxiosProgressEvent } from "axios";
|
||||||
import type disk from "diskusage";
|
import type disk from "diskusage";
|
||||||
@@ -126,6 +127,11 @@ declare global {
|
|||||||
shop: GameShop,
|
shop: GameShop,
|
||||||
objectId: string
|
objectId: string
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
toggleGamePin: (
|
||||||
|
shop: GameShop,
|
||||||
|
objectId: string,
|
||||||
|
pinned: boolean
|
||||||
|
) => Promise<void>;
|
||||||
updateLaunchOptions: (
|
updateLaunchOptions: (
|
||||||
shop: GameShop,
|
shop: GameShop,
|
||||||
objectId: string,
|
objectId: string,
|
||||||
@@ -287,6 +293,12 @@ declare global {
|
|||||||
|
|
||||||
/* User */
|
/* User */
|
||||||
getUser: (userId: string) => Promise<UserProfile | null>;
|
getUser: (userId: string) => Promise<UserProfile | null>;
|
||||||
|
getUserLibrary: (
|
||||||
|
userId: string,
|
||||||
|
take?: number,
|
||||||
|
skip?: number,
|
||||||
|
sortBy?: string
|
||||||
|
) => Promise<UserLibraryResponse>;
|
||||||
blockUser: (userId: string) => Promise<void>;
|
blockUser: (userId: string) => Promise<void>;
|
||||||
unblockUser: (userId: string) => Promise<void>;
|
unblockUser: (userId: string) => Promise<void>;
|
||||||
getUserFriends: (
|
getUserFriends: (
|
||||||
|
|||||||
27
src/renderer/src/hooks/use-section-collapse.ts
Normal file
27
src/renderer/src/hooks/use-section-collapse.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { useState, useCallback } from "react";
|
||||||
|
|
||||||
|
interface SectionCollapseState {
|
||||||
|
pinned: boolean;
|
||||||
|
library: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSectionCollapse() {
|
||||||
|
const [collapseState, setCollapseState] = useState<SectionCollapseState>({
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -3,11 +3,18 @@ import {
|
|||||||
GearIcon,
|
GearIcon,
|
||||||
HeartFillIcon,
|
HeartFillIcon,
|
||||||
HeartIcon,
|
HeartIcon,
|
||||||
|
PinIcon,
|
||||||
|
PinSlashIcon,
|
||||||
PlayIcon,
|
PlayIcon,
|
||||||
PlusCircleIcon,
|
PlusCircleIcon,
|
||||||
} from "@primer/octicons-react";
|
} from "@primer/octicons-react";
|
||||||
import { Button } from "@renderer/components";
|
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 { useContext, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { gameDetailsContext } from "@renderer/context";
|
import { gameDetailsContext } from "@renderer/context";
|
||||||
@@ -20,6 +27,7 @@ export function HeroPanelActions() {
|
|||||||
useState(false);
|
useState(false);
|
||||||
|
|
||||||
const { isGameDeleting } = useDownload();
|
const { isGameDeleting } = useDownload();
|
||||||
|
const { userDetails } = useUserDetails();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
game,
|
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 () => {
|
const openGame = async () => {
|
||||||
if (game) {
|
if (game) {
|
||||||
if (game.executablePath) {
|
if (game.executablePath) {
|
||||||
@@ -226,6 +257,17 @@ export function HeroPanelActions() {
|
|||||||
{game.favorite ? <HeartFillIcon /> : <HeartIcon />}
|
{game.favorite ? <HeartFillIcon /> : <HeartIcon />}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{userDetails && (
|
||||||
|
<Button
|
||||||
|
onClick={toggleGamePinned}
|
||||||
|
theme="outline"
|
||||||
|
disabled={deleting}
|
||||||
|
className="hero-panel-actions__action"
|
||||||
|
>
|
||||||
|
{game.isPinned ? <PinSlashIcon /> : <PinIcon />}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setShowGameOptionsModal(true)}
|
onClick={() => setShowGameOptionsModal(true)}
|
||||||
theme="outline"
|
theme="outline"
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import "./hero-panel-playtime.scss";
|
|||||||
|
|
||||||
export function HeroPanelPlaytime() {
|
export function HeroPanelPlaytime() {
|
||||||
const [lastTimePlayed, setLastTimePlayed] = useState("");
|
const [lastTimePlayed, setLastTimePlayed] = useState("");
|
||||||
|
|
||||||
|
|
||||||
const { game, isGameRunning } = useContext(gameDetailsContext);
|
const { game, isGameRunning } = useContext(gameDetailsContext);
|
||||||
const { t } = useTranslation("game_details");
|
const { t } = useTranslation("game_details");
|
||||||
@@ -89,7 +88,7 @@ export function HeroPanelPlaytime() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<p
|
<p
|
||||||
className="hero-panel-playtime__play-time"
|
className="hero-panel-playtime__play-time"
|
||||||
data-tooltip-place="top"
|
data-tooltip-place="top"
|
||||||
data-tooltip-content={
|
data-tooltip-content={
|
||||||
@@ -97,11 +96,15 @@ export function HeroPanelPlaytime() {
|
|||||||
? t("manual_playtime_tooltip")
|
? t("manual_playtime_tooltip")
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
data-tooltip-id={game.hasManuallyUpdatedPlaytime ? "manual-playtime-warning" : undefined}
|
data-tooltip-id={
|
||||||
|
game.hasManuallyUpdatedPlaytime
|
||||||
|
? "manual-playtime-warning"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{game.hasManuallyUpdatedPlaytime && (
|
{game.hasManuallyUpdatedPlaytime && (
|
||||||
<AlertFillIcon
|
<AlertFillIcon
|
||||||
size={16}
|
size={16}
|
||||||
className="hero-panel-playtime__manual-warning"
|
className="hero-panel-playtime__manual-warning"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -119,7 +122,7 @@ export function HeroPanelPlaytime() {
|
|||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{game.hasManuallyUpdatedPlaytime && (
|
{game.hasManuallyUpdatedPlaytime && (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
id="manual-playtime-warning"
|
id="manual-playtime-warning"
|
||||||
@@ -127,7 +130,6 @@ export function HeroPanelPlaytime() {
|
|||||||
zIndex: 9999,
|
zIndex: 9999,
|
||||||
}}
|
}}
|
||||||
openOnClick={false}
|
openOnClick={false}
|
||||||
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -72,7 +72,6 @@ export function ChangeGamePlaytimeModal({
|
|||||||
onSuccess?.(t("update_playtime_success"));
|
onSuccess?.(t("update_playtime_success"));
|
||||||
onClose();
|
onClose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error);
|
|
||||||
onError?.(t("update_playtime_error"));
|
onError?.(t("update_playtime_error"));
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
|
|||||||
@@ -12,6 +12,22 @@
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__filter-toggle {
|
||||||
|
align-self: flex-start;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: globals.$small-font-size;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
&__filter-toggle {
|
&__filter-toggle {
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -90,16 +106,18 @@
|
|||||||
margin-top: calc(globals.$spacing-unit * 0.5);
|
margin-top: calc(globals.$spacing-unit * 0.5);
|
||||||
max-height: 0;
|
max-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: max-height 0.3s ease, padding 0.3s ease;
|
transition:
|
||||||
|
max-height 0.3s ease,
|
||||||
|
padding 0.3s ease;
|
||||||
|
|
||||||
&--open {
|
&--open {
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
max-height: 250px; /* Ajuste baseado no conteúdo esperado */
|
max-height: 250px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__filter-label {
|
&__filter-label {
|
||||||
display: none; /* Escondido pois agora está no botão toggle */
|
display: none;
|
||||||
font-size: globals.$small-font-size;
|
font-size: globals.$small-font-size;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
@@ -110,15 +128,18 @@
|
|||||||
&__source-grid {
|
&__source-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
max-height: 200px;
|
max-height: 200px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
overflow-x: hidden;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
padding-right: 0.25rem; /* Espaço para a barra de rolagem */
|
padding-right: 0.25rem; /* Espaço para a barra de rolagem */
|
||||||
}
|
}
|
||||||
|
|
||||||
&__source-item {
|
&__source-item {
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
padding: 0.35rem 0.5rem;
|
padding: 0.35rem 0.5rem;
|
||||||
background: var(--color-surface, rgba(0, 0, 0, 0.03));
|
background: var(--color-surface, rgba(0, 0, 0, 0.03));
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
@@ -128,12 +149,12 @@
|
|||||||
min-height: 38px;
|
min-height: 38px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ajustes para o label do checkbox
|
|
||||||
&__source-item :global(.checkbox-field) {
|
&__source-item :global(.checkbox-field) {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 0; /* Permite que o flex item encolha abaixo da largura do conteúdo */
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__source-item :global(.checkbox-field__label) {
|
&__source-item :global(.checkbox-field__label) {
|
||||||
@@ -141,7 +162,7 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 0.85rem; /* Fonte levemente menor para caber melhor */
|
font-size: 0.85rem;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,8 +54,75 @@
|
|||||||
&__section-header {
|
&__section-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: calc(globals.$spacing-unit * 2);
|
margin-bottom: calc(globals.$spacing-unit * 2);
|
||||||
|
gap: calc(globals.$spacing-unit);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__section-title-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: calc(globals.$spacing-unit);
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__section-badge {
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
min-width: 24px;
|
||||||
|
text-align: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
&__collapse-button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all ease 0.2s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__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 {
|
&__games-grid {
|
||||||
|
|||||||
@@ -3,23 +3,45 @@ import { useContext, useEffect, useMemo, useRef, useState } from "react";
|
|||||||
import { ProfileHero } from "../profile-hero/profile-hero";
|
import { ProfileHero } from "../profile-hero/profile-hero";
|
||||||
import { useAppDispatch, useFormat } from "@renderer/hooks";
|
import { useAppDispatch, useFormat } from "@renderer/hooks";
|
||||||
import { setHeaderTitle } from "@renderer/features";
|
import { setHeaderTitle } from "@renderer/features";
|
||||||
import { TelescopeIcon } from "@primer/octicons-react";
|
import { TelescopeIcon, ChevronRightIcon } from "@primer/octicons-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { UserGame } from "@types";
|
||||||
import { LockedProfile } from "./locked-profile";
|
import { LockedProfile } from "./locked-profile";
|
||||||
import { ReportProfile } from "../report-profile/report-profile";
|
import { ReportProfile } from "../report-profile/report-profile";
|
||||||
import { FriendsBox } from "./friends-box";
|
import { FriendsBox } from "./friends-box";
|
||||||
import { RecentGamesBox } from "./recent-games-box";
|
import { RecentGamesBox } from "./recent-games-box";
|
||||||
import { UserStatsBox } from "./user-stats-box";
|
import { UserStatsBox } from "./user-stats-box";
|
||||||
import { UserLibraryGameCard } from "./user-library-game-card";
|
import { UserLibraryGameCard } from "./user-library-game-card";
|
||||||
|
import { SortOptions } from "./sort-options";
|
||||||
|
import { useSectionCollapse } from "@renderer/hooks/use-section-collapse";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import {
|
||||||
|
sectionVariants,
|
||||||
|
gameCardVariants,
|
||||||
|
gameGridVariants,
|
||||||
|
chevronVariants,
|
||||||
|
GAME_STATS_ANIMATION_DURATION_IN_MS,
|
||||||
|
} from "./profile-animations";
|
||||||
import "./profile-content.scss";
|
import "./profile-content.scss";
|
||||||
|
|
||||||
const GAME_STATS_ANIMATION_DURATION_IN_MS = 3500;
|
type SortOption = "playtime" | "achievementCount" | "playedRecently";
|
||||||
|
|
||||||
export function ProfileContent() {
|
export function ProfileContent() {
|
||||||
const { userProfile, isMe, userStats } = useContext(userProfileContext);
|
const {
|
||||||
|
userProfile,
|
||||||
|
isMe,
|
||||||
|
userStats,
|
||||||
|
libraryGames,
|
||||||
|
pinnedGames,
|
||||||
|
getUserLibraryGames,
|
||||||
|
} = useContext(userProfileContext);
|
||||||
const [statsIndex, setStatsIndex] = useState(0);
|
const [statsIndex, setStatsIndex] = useState(0);
|
||||||
const [isAnimationRunning, setIsAnimationRunning] = useState(true);
|
const [isAnimationRunning, setIsAnimationRunning] = useState(true);
|
||||||
|
const [sortBy, setSortBy] = useState<SortOption>("playedRecently");
|
||||||
|
const [prevLibraryGames, setPrevLibraryGames] = useState<UserGame[]>([]);
|
||||||
|
const [prevPinnedGames, setPrevPinnedGames] = useState<UserGame[]>([]);
|
||||||
const statsAnimation = useRef(-1);
|
const statsAnimation = useRef(-1);
|
||||||
|
const { toggleSection, isPinnedCollapsed } = useSectionCollapse();
|
||||||
|
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
@@ -33,6 +55,12 @@ export function ProfileContent() {
|
|||||||
}
|
}
|
||||||
}, [userProfile, dispatch]);
|
}, [userProfile, dispatch]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (userProfile) {
|
||||||
|
getUserLibraryGames(sortBy);
|
||||||
|
}
|
||||||
|
}, [sortBy, getUserLibraryGames, userProfile]);
|
||||||
|
|
||||||
const handleOnMouseEnterGameCard = () => {
|
const handleOnMouseEnterGameCard = () => {
|
||||||
setIsAnimationRunning(false);
|
setIsAnimationRunning(false);
|
||||||
};
|
};
|
||||||
@@ -64,6 +92,27 @@ export function ProfileContent() {
|
|||||||
|
|
||||||
const { numberFormatter } = useFormat();
|
const { numberFormatter } = useFormat();
|
||||||
|
|
||||||
|
const gamesHaveChanged = (
|
||||||
|
current: UserGame[],
|
||||||
|
previous: UserGame[]
|
||||||
|
): boolean => {
|
||||||
|
if (current.length !== previous.length) return true;
|
||||||
|
return current.some(
|
||||||
|
(game, index) => game.objectId !== previous[index]?.objectId
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const shouldAnimateLibrary = gamesHaveChanged(libraryGames, prevLibraryGames);
|
||||||
|
const shouldAnimatePinned = gamesHaveChanged(pinnedGames, prevPinnedGames);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPrevLibraryGames(libraryGames);
|
||||||
|
}, [libraryGames]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPrevPinnedGames(pinnedGames);
|
||||||
|
}, [pinnedGames]);
|
||||||
|
|
||||||
const usersAreFriends = useMemo(() => {
|
const usersAreFriends = useMemo(() => {
|
||||||
return userProfile?.relation?.status === "ACCEPTED";
|
return userProfile?.relation?.status === "ACCEPTED";
|
||||||
}, [userProfile]);
|
}, [userProfile]);
|
||||||
@@ -79,14 +128,21 @@ export function ProfileContent() {
|
|||||||
return <LockedProfile />;
|
return <LockedProfile />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasGames = userProfile?.libraryGames.length > 0;
|
const hasGames = libraryGames.length > 0;
|
||||||
|
const hasPinnedGames = pinnedGames.length > 0;
|
||||||
|
const hasAnyGames = hasGames || hasPinnedGames;
|
||||||
|
|
||||||
const shouldShowRightContent = hasGames || userProfile.friends.length > 0;
|
const shouldShowRightContent =
|
||||||
|
hasAnyGames || userProfile.friends.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="profile-content__section">
|
<section className="profile-content__section">
|
||||||
<div className="profile-content__main">
|
<div className="profile-content__main">
|
||||||
{!hasGames && (
|
{hasAnyGames && (
|
||||||
|
<SortOptions sortBy={sortBy} onSortChange={setSortBy} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!hasAnyGames && (
|
||||||
<div className="profile-content__no-games">
|
<div className="profile-content__no-games">
|
||||||
<div className="profile-content__telescope-icon">
|
<div className="profile-content__telescope-icon">
|
||||||
<TelescopeIcon size={24} />
|
<TelescopeIcon size={24} />
|
||||||
@@ -96,28 +152,167 @@ export function ProfileContent() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasGames && (
|
{hasAnyGames && (
|
||||||
<>
|
<div>
|
||||||
<div className="profile-content__section-header">
|
{hasPinnedGames && (
|
||||||
<h2>{t("library")}</h2>
|
<div style={{ marginBottom: "2rem" }}>
|
||||||
|
<div className="profile-content__section-header">
|
||||||
|
<div className="profile-content__section-title-group">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="profile-content__collapse-button"
|
||||||
|
onClick={() => toggleSection("pinned")}
|
||||||
|
aria-label={
|
||||||
|
isPinnedCollapsed
|
||||||
|
? "Expand pinned section"
|
||||||
|
: "Collapse pinned section"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
variants={chevronVariants}
|
||||||
|
animate={isPinnedCollapsed ? "collapsed" : "expanded"}
|
||||||
|
>
|
||||||
|
<ChevronRightIcon size={16} />
|
||||||
|
</motion.div>
|
||||||
|
</button>
|
||||||
|
<h2>{t("pinned")}</h2>
|
||||||
|
<span className="profile-content__section-badge">
|
||||||
|
{pinnedGames.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{userStats && (
|
<AnimatePresence initial={true} mode="wait">
|
||||||
<span>{numberFormatter.format(userStats.libraryCount)}</span>
|
{!isPinnedCollapsed && (
|
||||||
)}
|
<motion.div
|
||||||
</div>
|
key="pinned-content"
|
||||||
|
variants={sectionVariants}
|
||||||
|
initial="collapsed"
|
||||||
|
animate="expanded"
|
||||||
|
exit="collapsed"
|
||||||
|
layout
|
||||||
|
>
|
||||||
|
<motion.ul
|
||||||
|
className="profile-content__games-grid"
|
||||||
|
variants={
|
||||||
|
shouldAnimatePinned ? gameGridVariants : undefined
|
||||||
|
}
|
||||||
|
initial={shouldAnimatePinned ? "hidden" : undefined}
|
||||||
|
animate={shouldAnimatePinned ? "visible" : undefined}
|
||||||
|
exit={shouldAnimatePinned ? "exit" : undefined}
|
||||||
|
key={
|
||||||
|
shouldAnimatePinned
|
||||||
|
? `pinned-${sortBy}`
|
||||||
|
: `pinned-static`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{shouldAnimatePinned ? (
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{pinnedGames?.map((game, index) => (
|
||||||
|
<motion.li
|
||||||
|
key={game.objectId}
|
||||||
|
variants={gameCardVariants}
|
||||||
|
initial="hidden"
|
||||||
|
animate="visible"
|
||||||
|
exit="exit"
|
||||||
|
transition={{ delay: index * 0.1 }}
|
||||||
|
style={{ listStyle: "none" }}
|
||||||
|
>
|
||||||
|
<UserLibraryGameCard
|
||||||
|
game={game}
|
||||||
|
statIndex={statsIndex}
|
||||||
|
onMouseEnter={handleOnMouseEnterGameCard}
|
||||||
|
onMouseLeave={handleOnMouseLeaveGameCard}
|
||||||
|
/>
|
||||||
|
</motion.li>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
) : (
|
||||||
|
pinnedGames?.map((game) => (
|
||||||
|
<li
|
||||||
|
key={game.objectId}
|
||||||
|
style={{ listStyle: "none" }}
|
||||||
|
>
|
||||||
|
<UserLibraryGameCard
|
||||||
|
game={game}
|
||||||
|
statIndex={statsIndex}
|
||||||
|
onMouseEnter={handleOnMouseEnterGameCard}
|
||||||
|
onMouseLeave={handleOnMouseLeaveGameCard}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</motion.ul>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<ul className="profile-content__games-grid">
|
{hasGames && (
|
||||||
{userProfile?.libraryGames?.map((game) => (
|
<div>
|
||||||
<UserLibraryGameCard
|
<div className="profile-content__section-header">
|
||||||
game={game}
|
<div className="profile-content__section-title-group">
|
||||||
key={game.objectId}
|
<h2>{t("library")}</h2>
|
||||||
statIndex={statsIndex}
|
{userStats && (
|
||||||
onMouseEnter={handleOnMouseEnterGameCard}
|
<span className="profile-content__section-badge">
|
||||||
onMouseLeave={handleOnMouseLeaveGameCard}
|
{numberFormatter.format(userStats.libraryCount)}
|
||||||
/>
|
</span>
|
||||||
))}
|
)}
|
||||||
</ul>
|
</div>
|
||||||
</>
|
</div>
|
||||||
|
|
||||||
|
<motion.ul
|
||||||
|
className="profile-content__games-grid"
|
||||||
|
variants={
|
||||||
|
shouldAnimateLibrary ? gameGridVariants : undefined
|
||||||
|
}
|
||||||
|
initial={shouldAnimateLibrary ? "hidden" : undefined}
|
||||||
|
animate={shouldAnimateLibrary ? "visible" : undefined}
|
||||||
|
exit={shouldAnimateLibrary ? "exit" : undefined}
|
||||||
|
key={
|
||||||
|
shouldAnimateLibrary
|
||||||
|
? `library-${sortBy}`
|
||||||
|
: `library-static`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{shouldAnimateLibrary ? (
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{libraryGames?.map((game, index) => (
|
||||||
|
<motion.li
|
||||||
|
key={game.objectId}
|
||||||
|
variants={gameCardVariants}
|
||||||
|
initial="hidden"
|
||||||
|
animate="visible"
|
||||||
|
exit="exit"
|
||||||
|
transition={{ delay: index * 0.1 }}
|
||||||
|
style={{ listStyle: "none" }}
|
||||||
|
>
|
||||||
|
<UserLibraryGameCard
|
||||||
|
game={game}
|
||||||
|
statIndex={statsIndex}
|
||||||
|
onMouseEnter={handleOnMouseEnterGameCard}
|
||||||
|
onMouseLeave={handleOnMouseLeaveGameCard}
|
||||||
|
/>
|
||||||
|
</motion.li>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
) : (
|
||||||
|
libraryGames?.map((game) => (
|
||||||
|
<li key={game.objectId} style={{ listStyle: "none" }}>
|
||||||
|
<UserLibraryGameCard
|
||||||
|
game={game}
|
||||||
|
statIndex={statsIndex}
|
||||||
|
onMouseEnter={handleOnMouseEnterGameCard}
|
||||||
|
onMouseLeave={handleOnMouseLeaveGameCard}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</motion.ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -139,6 +334,13 @@ export function ProfileContent() {
|
|||||||
numberFormatter,
|
numberFormatter,
|
||||||
t,
|
t,
|
||||||
statsIndex,
|
statsIndex,
|
||||||
|
libraryGames,
|
||||||
|
pinnedGames,
|
||||||
|
isPinnedCollapsed,
|
||||||
|
toggleSection,
|
||||||
|
shouldAnimateLibrary,
|
||||||
|
shouldAnimatePinned,
|
||||||
|
sortBy,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -65,6 +65,47 @@
|
|||||||
padding: 8px;
|
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 {
|
&__playtime {
|
||||||
background-color: globals.$background-color;
|
background-color: globals.$background-color;
|
||||||
color: globals.$muted-color;
|
color: globals.$muted-color;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { UserGame } from "@types";
|
import { UserGame } from "@types";
|
||||||
import HydraIcon from "@renderer/assets/icons/hydra.svg?react";
|
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 { useNavigate } from "react-router-dom";
|
||||||
import { useCallback, useContext, useState } from "react";
|
import { useCallback, useContext, useState } from "react";
|
||||||
import {
|
import {
|
||||||
@@ -9,7 +9,14 @@ import {
|
|||||||
formatDownloadProgress,
|
formatDownloadProgress,
|
||||||
} from "@renderer/helpers";
|
} from "@renderer/helpers";
|
||||||
import { userProfileContext } from "@renderer/context";
|
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 { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
|
||||||
import { Tooltip } from "react-tooltip";
|
import { Tooltip } from "react-tooltip";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -28,11 +35,14 @@ export function UserLibraryGameCard({
|
|||||||
onMouseEnter,
|
onMouseEnter,
|
||||||
onMouseLeave,
|
onMouseLeave,
|
||||||
}: UserLibraryGameCardProps) {
|
}: UserLibraryGameCardProps) {
|
||||||
const { userProfile } = useContext(userProfileContext);
|
const { userProfile, isMe, getUserLibraryGames } =
|
||||||
|
useContext(userProfileContext);
|
||||||
const { t } = useTranslation("user_profile");
|
const { t } = useTranslation("user_profile");
|
||||||
const { numberFormatter } = useFormat();
|
const { numberFormatter } = useFormat();
|
||||||
|
const { showSuccessToast } = useToast();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [isTooltipHovered, setIsTooltipHovered] = useState(false);
|
const [isTooltipHovered, setIsTooltipHovered] = useState(false);
|
||||||
|
const [isPinning, setIsPinning] = useState(false);
|
||||||
|
|
||||||
const getStatsItemCount = useCallback(() => {
|
const getStatsItemCount = useCallback(() => {
|
||||||
let statsCount = 1;
|
let statsCount = 1;
|
||||||
@@ -84,6 +94,28 @@ export function UserLibraryGameCard({
|
|||||||
[numberFormatter, t]
|
[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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<li
|
<li
|
||||||
@@ -98,6 +130,32 @@ export function UserLibraryGameCard({
|
|||||||
onClick={() => navigate(buildUserGameDetailsPath(game))}
|
onClick={() => navigate(buildUserGameDetailsPath(game))}
|
||||||
>
|
>
|
||||||
<div className="user-library-game__overlay">
|
<div className="user-library-game__overlay">
|
||||||
|
{(game.isFavorite || isMe) && (
|
||||||
|
<div className="user-library-game__actions-container">
|
||||||
|
{game.isFavorite && (
|
||||||
|
<div className="user-library-game__favorite-icon">
|
||||||
|
<HeartFillIcon size={12} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isMe && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="user-library-game__pin-button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleGamePinned();
|
||||||
|
}}
|
||||||
|
disabled={isPinning}
|
||||||
|
>
|
||||||
|
{game.isPinned ? (
|
||||||
|
<PinSlashIcon size={12} />
|
||||||
|
) : (
|
||||||
|
<PinIcon size={12} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<small
|
<small
|
||||||
className="user-library-game__playtime"
|
className="user-library-game__playtime"
|
||||||
data-tooltip-place="top"
|
data-tooltip-place="top"
|
||||||
|
|||||||
@@ -71,8 +71,17 @@ export type UserGame = {
|
|||||||
achievementCount: number;
|
achievementCount: number;
|
||||||
achievementsPointsEarnedSum: number;
|
achievementsPointsEarnedSum: number;
|
||||||
hasManuallyUpdatedPlaytime: boolean;
|
hasManuallyUpdatedPlaytime: boolean;
|
||||||
|
isFavorite: boolean;
|
||||||
|
isPinned: boolean;
|
||||||
|
pinnedDate?: Date | null;
|
||||||
} & ShopAssets;
|
} & ShopAssets;
|
||||||
|
|
||||||
|
export interface UserLibraryResponse {
|
||||||
|
totalCount: number;
|
||||||
|
library: UserGame[];
|
||||||
|
pinnedGames: UserGame[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface GameRunning {
|
export interface GameRunning {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ export interface Game {
|
|||||||
executablePath?: string | null;
|
executablePath?: string | null;
|
||||||
launchOptions?: string | null;
|
launchOptions?: string | null;
|
||||||
favorite?: boolean;
|
favorite?: boolean;
|
||||||
|
isPinned?: boolean;
|
||||||
|
pinnedDate?: Date | null;
|
||||||
automaticCloudSync?: boolean;
|
automaticCloudSync?: boolean;
|
||||||
hasManuallyUpdatedPlaytime?: boolean;
|
hasManuallyUpdatedPlaytime?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user