mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-11 22:06:17 +00:00
Compare commits
40 Commits
v3.6.6
...
feat/pinni
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa152385b1 | ||
|
|
b6be03cea3 | ||
|
|
f027f05e02 | ||
|
|
56391837cd | ||
|
|
31bd523038 | ||
|
|
fd1f13225b | ||
|
|
dba4b2c4be | ||
|
|
05f10fd80b | ||
|
|
d59315e322 | ||
|
|
a29f2ba741 | ||
|
|
d33b0099a1 | ||
|
|
cad50649aa | ||
|
|
b3148855bc | ||
|
|
f56a3ea126 | ||
|
|
eea701f046 | ||
|
|
355d38c0a2 | ||
|
|
366da7839f | ||
|
|
a869902cfb | ||
|
|
f9d51ed33d | ||
|
|
092af7e421 | ||
|
|
33c15baf0e | ||
|
|
a7e4e21167 | ||
|
|
b22e082781 | ||
|
|
c056feb26f | ||
|
|
240b0705d5 | ||
|
|
2604dfea22 | ||
|
|
0adea20565 | ||
|
|
f182c7c8e9 | ||
|
|
d9379fbcb9 | ||
|
|
adc4af731e | ||
|
|
af1b3d4535 | ||
|
|
291935a1bc | ||
|
|
665365abdc | ||
|
|
86da92aa3f | ||
|
|
6ff694c078 | ||
|
|
01ac5239dc | ||
|
|
1dc2176813 | ||
|
|
a7ec632a21 | ||
|
|
2b6d8eba78 | ||
|
|
6bc6a10d66 |
@@ -1,5 +1,5 @@
|
||||
MAIN_VITE_API_URL=API_URL
|
||||
MAIN_VITE_AUTH_URL=AUTH_URL
|
||||
MAIN_VITE_API_URL=
|
||||
MAIN_VITE_AUTH_URL=
|
||||
MAIN_VITE_WS_URL=
|
||||
RENDERER_VITE_REAL_DEBRID_REFERRAL_ID=
|
||||
RENDERER_VITE_TORBOX_REFERRAL_CODE=
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hydralauncher",
|
||||
"version": "3.6.6",
|
||||
"version": "3.6.8",
|
||||
"description": "Hydra",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "Los Broxas",
|
||||
|
||||
@@ -198,10 +198,20 @@
|
||||
"download_error_gofile_quota_exceeded": "You have exceeded your Gofile monthly quota. Please await the quota to reset.",
|
||||
"download_error_real_debrid_account_not_authorized": "Your Real-Debrid account is not authorized to make new downloads. Please check your account settings and try again.",
|
||||
"download_error_not_cached_on_real_debrid": "This download is not available on Real-Debrid and polling download status from Real-Debrid is not yet available.",
|
||||
"update_playtime_title": "Update playtime",
|
||||
"update_playtime_description": "Manually update the playtime for {{game}}",
|
||||
"update_playtime": "Update playtime",
|
||||
"update_playtime_success": "Playtime updated successfully",
|
||||
"update_playtime_error": "Failed to update playtime",
|
||||
"update_game_playtime": "Update game playtime",
|
||||
"manual_playtime_warning": "Your hours will be marked as manually updated, and this cannot be undone.",
|
||||
"manual_playtime_tooltip": "This playtime has been manually updated",
|
||||
"download_error_not_cached_on_torbox": "This download is not available on TorBox and polling download status from TorBox is not yet available.",
|
||||
"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",
|
||||
@@ -443,7 +453,9 @@
|
||||
"last_time_played": "Last played {{period}}",
|
||||
"activity": "Recent Activity",
|
||||
"library": "Library",
|
||||
"pinned": "Pinned",
|
||||
"total_play_time": "Total playtime",
|
||||
"manual_playtime_tooltip": "This playtime has been manually updated",
|
||||
"no_recent_activity_title": "Hmmm… nothing here",
|
||||
"no_recent_activity_description": "You haven't played any games recently. It's time to change that!",
|
||||
"display_name": "Display name",
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
"hot": "Сейчас популярно",
|
||||
"start_typing": "Начинаю вводить текст...",
|
||||
"weekly": "📅 Лучшие игры недели",
|
||||
"achievements": "🏆 Игры с достижениями"
|
||||
"achievements": "🏆 Игры с достижениями",
|
||||
"already_in_library": "Уже в библиотеке"
|
||||
},
|
||||
"sidebar": {
|
||||
"catalogue": "Каталог",
|
||||
@@ -195,6 +196,10 @@
|
||||
"download_error_gofile_quota_exceeded": "Вы превысили месячную квоту Gofile. Пожалуйста, подождите, пока квота не будет восстановлена.",
|
||||
"download_error_real_debrid_account_not_authorized": "Ваш аккаунт Real-Debrid не авторизован для осуществления новых загрузок. Пожалуйста, проверьте настройки учетной записи и повторите попытку.",
|
||||
"download_error_not_cached_on_real_debrid": "Эта загрузка недоступна на Real-Debrid, и получение статуса загрузки с Real-Debrid пока недоступно.",
|
||||
"update_playtime_title": "Обновить время игры",
|
||||
"update_playtime_description": "Вручную обновите время игры для {{game}}",
|
||||
"update_playtime": "Обновить время игры",
|
||||
"update_game_playtime": "Обновить время игры",
|
||||
"download_error_not_cached_on_torbox": "Эта загрузка недоступна на TorBox, и получить статус загрузки с TorBox пока невозможно.",
|
||||
"game_added_to_favorites": "Игра добавлена в избранное",
|
||||
"game_removed_from_favorites": "Игра удалена из избранного",
|
||||
@@ -205,7 +210,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",
|
||||
@@ -381,7 +402,8 @@
|
||||
"hidden": "Скрытый",
|
||||
"test_notification": "Тестовое уведомление",
|
||||
"notification_preview": "Предварительный просмотр уведомления о достижении",
|
||||
"enable_friend_start_game_notifications": "Когда друг начинает играть в игру"
|
||||
"enable_friend_start_game_notifications": "Когда друг начинает играть в игру",
|
||||
"enable_steam_achievements": "Включить поиск достижений Steam"
|
||||
},
|
||||
"notifications": {
|
||||
"download_complete": "Загрузка завершена",
|
||||
@@ -394,12 +416,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",
|
||||
@@ -428,6 +450,7 @@
|
||||
"activity": "Недавняя активность",
|
||||
"library": "Библиотека",
|
||||
"total_play_time": "Всего сыграно",
|
||||
"manual_playtime_tooltip": "Время игры было обновлено вручную",
|
||||
"no_recent_activity_title": "Хммм... Тут ничего нет",
|
||||
"no_recent_activity_description": "Вы давно ни во что не играли. Пора это изменить!",
|
||||
"display_name": "Отображаемое имя",
|
||||
@@ -500,7 +523,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": "Достижение разблокировано",
|
||||
|
||||
@@ -16,6 +16,8 @@ import "./hardware/check-folder-write-permission";
|
||||
import "./library/add-game-to-library";
|
||||
import "./library/add-game-to-favorites";
|
||||
import "./library/remove-game-from-favorites";
|
||||
import "./library/add-game-to-pinned";
|
||||
import "./library/remove-game-from-pinned";
|
||||
import "./library/create-game-shortcut";
|
||||
import "./library/close-game";
|
||||
import "./library/delete-game-folder";
|
||||
@@ -33,6 +35,7 @@ import "./library/remove-game";
|
||||
import "./library/remove-game-from-library";
|
||||
import "./library/select-game-wine-prefix";
|
||||
import "./library/reset-game-achievements";
|
||||
import "./library/change-game-playtime";
|
||||
import "./library/toggle-automatic-cloud-sync";
|
||||
import "./library/get-default-wine-prefix-selection-path";
|
||||
import "./library/create-steam-shortcut";
|
||||
@@ -63,6 +66,7 @@ import "./auth/sign-out";
|
||||
import "./auth/open-auth-window";
|
||||
import "./auth/get-session-hash";
|
||||
import "./user/get-user";
|
||||
import "./user/get-user-library";
|
||||
import "./user/get-blocked-users";
|
||||
import "./user/block-user";
|
||||
import "./user/unblock-user";
|
||||
|
||||
29
src/main/events/library/add-game-to-pinned.ts
Normal file
29
src/main/events/library/add-game-to-pinned.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { gamesSublevel, levelKeys } from "@main/level";
|
||||
import { HydraApi } from "@main/services";
|
||||
import type { GameShop } from "@types";
|
||||
|
||||
const addGameToPinned = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
shop: GameShop,
|
||||
objectId: string
|
||||
) => {
|
||||
const gameKey = levelKeys.game(shop, objectId);
|
||||
|
||||
const game = await gamesSublevel.get(gameKey);
|
||||
if (!game) return;
|
||||
|
||||
const response = await HydraApi.put(`/profile/games/${shop}/${objectId}/pin`);
|
||||
|
||||
try {
|
||||
await gamesSublevel.put(gameKey, {
|
||||
...game,
|
||||
pinned: true,
|
||||
pinnedDate: new Date(response.pinnedDate),
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to update game pinned status: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
registerEvent("addGameToPinned", addGameToPinned);
|
||||
29
src/main/events/library/change-game-playtime.ts
Normal file
29
src/main/events/library/change-game-playtime.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { HydraApi } from "@main/services";
|
||||
import { registerEvent } from "../register-event";
|
||||
import { GameShop } from "@types";
|
||||
import { gamesSublevel, levelKeys } from "@main/level";
|
||||
|
||||
const changeGamePlaytime = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
shop: GameShop,
|
||||
objectId: string,
|
||||
playTimeInSeconds: number
|
||||
) => {
|
||||
try {
|
||||
const gameKey = levelKeys.game(shop, objectId);
|
||||
const game = await gamesSublevel.get(gameKey);
|
||||
if (!game) return;
|
||||
await HydraApi.put(`/profile/games/${shop}/${objectId}/playtime`, {
|
||||
playTimeInSeconds,
|
||||
});
|
||||
await gamesSublevel.put(gameKey, {
|
||||
...game,
|
||||
playTimeInMilliseconds: playTimeInSeconds * 1000,
|
||||
hasManuallyUpdatedPlaytime: true,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to update game favorite status: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
registerEvent("changeGamePlayTime", changeGamePlaytime);
|
||||
29
src/main/events/library/remove-game-from-pinned.ts
Normal file
29
src/main/events/library/remove-game-from-pinned.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { gamesSublevel, levelKeys } from "@main/level";
|
||||
import { HydraApi } from "@main/services";
|
||||
import type { GameShop } from "@types";
|
||||
|
||||
const removeGameFromPinned = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
shop: GameShop,
|
||||
objectId: string
|
||||
) => {
|
||||
const gameKey = levelKeys.game(shop, objectId);
|
||||
|
||||
const game = await gamesSublevel.get(gameKey);
|
||||
if (!game) return;
|
||||
|
||||
HydraApi.put(`/profile/games/${shop}/${objectId}/unpin`).catch(() => {});
|
||||
|
||||
try {
|
||||
await gamesSublevel.put(gameKey, {
|
||||
...game,
|
||||
pinned: false,
|
||||
pinnedDate: null,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to update game pinned status: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
registerEvent("removeGameFromPinned", removeGameFromPinned);
|
||||
23
src/main/events/user/get-user-library.ts
Normal file
23
src/main/events/user/get-user-library.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { HydraApi } from "@main/services";
|
||||
import type { UserLibraryResponse } from "@types";
|
||||
|
||||
const getUserLibrary = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
userId: string,
|
||||
take: number = 12,
|
||||
skip: number = 0
|
||||
): Promise<UserLibraryResponse | null> => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
params.append("take", take.toString());
|
||||
params.append("skip", skip.toString());
|
||||
|
||||
const queryString = params.toString();
|
||||
const baseUrl = `/users/${userId}/library`;
|
||||
const url = queryString ? `${baseUrl}?${queryString}` : baseUrl;
|
||||
|
||||
return HydraApi.get<UserLibraryResponse>(url).catch(() => null);
|
||||
};
|
||||
|
||||
registerEvent("getUserLibrary", getUserLibrary);
|
||||
@@ -4,7 +4,6 @@ import { Downloader } from "@shared";
|
||||
import { levelKeys, db } from "./level";
|
||||
import type { UserPreferences } from "@types";
|
||||
import {
|
||||
WSClient,
|
||||
SystemPath,
|
||||
CommonRedistManager,
|
||||
TorBoxClient,
|
||||
@@ -47,7 +46,7 @@ export const loadState = async () => {
|
||||
|
||||
await HydraApi.setupApi().then(() => {
|
||||
uploadGamesBatch();
|
||||
WSClient.connect();
|
||||
// WSClient.connect();
|
||||
});
|
||||
|
||||
const downloads = await downloadsSublevel
|
||||
|
||||
@@ -11,7 +11,6 @@ import { getUserData } from "./user/get-user-data";
|
||||
import { db } from "@main/level";
|
||||
import { levelKeys } from "@main/level/sublevels";
|
||||
import type { Auth, User } from "@types";
|
||||
import { WSClient } from "./ws/ws-client";
|
||||
|
||||
interface HydraApiOptions {
|
||||
needsAuth?: boolean;
|
||||
@@ -103,8 +102,8 @@ export class HydraApi {
|
||||
WindowManager.mainWindow.webContents.send("on-signin");
|
||||
await clearGamesRemoteIds();
|
||||
uploadGamesBatch();
|
||||
WSClient.close();
|
||||
WSClient.connect();
|
||||
// WSClient.close();
|
||||
// WSClient.connect();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,9 @@ type ProfileGame = {
|
||||
id: string;
|
||||
lastTimePlayed: Date | null;
|
||||
playTimeInMilliseconds: number;
|
||||
hasManuallyUpdatedPlaytime: boolean;
|
||||
isFavorite?: boolean;
|
||||
isPinned?: boolean;
|
||||
} & ShopAssets;
|
||||
|
||||
export const mergeWithRemoteGames = async () => {
|
||||
@@ -35,6 +37,7 @@ export const mergeWithRemoteGames = async () => {
|
||||
lastTimePlayed: updatedLastTimePlayed,
|
||||
playTimeInMilliseconds: updatedPlayTime,
|
||||
favorite: game.isFavorite ?? localGame.favorite,
|
||||
pinned: game.isPinned ?? localGame.pinned,
|
||||
});
|
||||
} else {
|
||||
await gamesSublevel.put(gameKey, {
|
||||
@@ -45,8 +48,10 @@ export const mergeWithRemoteGames = async () => {
|
||||
iconUrl: game.iconUrl,
|
||||
lastTimePlayed: game.lastTimePlayed,
|
||||
playTimeInMilliseconds: game.playTimeInMilliseconds,
|
||||
hasManuallyUpdatedPlaytime: game.hasManuallyUpdatedPlaytime,
|
||||
isDeleted: false,
|
||||
favorite: game.isFavorite ?? false,
|
||||
pinned: game.isPinned ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ export const uploadGamesBatch = async () => {
|
||||
shop: game.shop,
|
||||
lastTimePlayed: game.lastTimePlayed,
|
||||
isFavorite: game.favorite,
|
||||
isPinned: game.pinned ?? false,
|
||||
};
|
||||
})
|
||||
).catch(() => {});
|
||||
|
||||
@@ -82,17 +82,19 @@ export const getSteamAppDetails = async (
|
||||
};
|
||||
|
||||
export const getSteamUsersIds = async () => {
|
||||
const userDataPath = await getSteamLocation().catch(() => null);
|
||||
const steamLocation = await getSteamLocation().catch(() => null);
|
||||
|
||||
if (!userDataPath) {
|
||||
if (!steamLocation) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const userDataPath = path.join(steamLocation, "userdata");
|
||||
|
||||
if (!fs.existsSync(userDataPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const userIds = fs.readdirSync(path.join(userDataPath, "userdata"), {
|
||||
const userIds = fs.readdirSync(userDataPath, {
|
||||
withFileTypes: true,
|
||||
});
|
||||
|
||||
|
||||
@@ -181,6 +181,9 @@ export class WindowManager {
|
||||
});
|
||||
|
||||
this.mainWindow.on("close", async () => {
|
||||
const mainWindow = this.mainWindow;
|
||||
this.mainWindow = null;
|
||||
|
||||
const userPreferences = await db.get<string, UserPreferences>(
|
||||
levelKeys.userPreferences,
|
||||
{
|
||||
@@ -188,9 +191,11 @@ export class WindowManager {
|
||||
}
|
||||
);
|
||||
|
||||
if (this.mainWindow) {
|
||||
const lastBounds = this.mainWindow.getBounds();
|
||||
const isMaximized = this.mainWindow.isMaximized() ?? false;
|
||||
if (mainWindow) {
|
||||
mainWindow.setProgressBar(-1);
|
||||
|
||||
const lastBounds = mainWindow.getBounds();
|
||||
const isMaximized = mainWindow.isMaximized() ?? false;
|
||||
const screenConfig = isMaximized
|
||||
? {
|
||||
x: undefined,
|
||||
@@ -207,9 +212,6 @@ export class WindowManager {
|
||||
if (userPreferences?.preferQuitInsteadOfHiding) {
|
||||
app.quit();
|
||||
}
|
||||
|
||||
WindowManager.mainWindow?.setProgressBar(-1);
|
||||
WindowManager.mainWindow = null;
|
||||
});
|
||||
|
||||
this.mainWindow.webContents.setWindowOpenHandler((handler) => {
|
||||
@@ -584,7 +586,7 @@ export class WindowManager {
|
||||
|
||||
tray.setToolTip("Hydra Launcher");
|
||||
|
||||
if (process.platform !== "darwin") {
|
||||
if (process.platform === "win32") {
|
||||
await updateSystemTray();
|
||||
|
||||
tray.addListener("double-click", () => {
|
||||
@@ -595,6 +597,18 @@ export class WindowManager {
|
||||
}
|
||||
});
|
||||
|
||||
tray.addListener("right-click", showContextMenu);
|
||||
} else if (process.platform === "linux") {
|
||||
await updateSystemTray();
|
||||
|
||||
tray.addListener("click", () => {
|
||||
if (this.mainWindow) {
|
||||
this.mainWindow.show();
|
||||
} else {
|
||||
this.createMainWindow();
|
||||
}
|
||||
});
|
||||
|
||||
tray.addListener("right-click", showContextMenu);
|
||||
} else {
|
||||
tray.addListener("click", showContextMenu);
|
||||
|
||||
@@ -143,6 +143,10 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
ipcRenderer.invoke("addGameToFavorites", shop, objectId),
|
||||
removeGameFromFavorites: (shop: GameShop, objectId: string) =>
|
||||
ipcRenderer.invoke("removeGameFromFavorites", shop, objectId),
|
||||
addGameToPinned: (shop: GameShop, objectId: string) =>
|
||||
ipcRenderer.invoke("addGameToPinned", shop, objectId),
|
||||
removeGameFromPinned: (shop: GameShop, objectId: string) =>
|
||||
ipcRenderer.invoke("removeGameFromPinned", shop, objectId),
|
||||
updateLaunchOptions: (
|
||||
shop: GameShop,
|
||||
objectId: string,
|
||||
@@ -189,6 +193,8 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
ipcRenderer.invoke("getGameByObjectId", shop, objectId),
|
||||
resetGameAchievements: (shop: GameShop, objectId: string) =>
|
||||
ipcRenderer.invoke("resetGameAchievements", shop, objectId),
|
||||
changeGamePlayTime: (shop: GameShop, objectId: string, playtime: number) =>
|
||||
ipcRenderer.invoke("changeGamePlayTime", shop, objectId, playtime),
|
||||
extractGameDownload: (shop: GameShop, objectId: string) =>
|
||||
ipcRenderer.invoke("extractGameDownload", shop, objectId),
|
||||
getDefaultWinePrefixSelectionPath: () =>
|
||||
@@ -364,6 +370,8 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
|
||||
/* User */
|
||||
getUser: (userId: string) => ipcRenderer.invoke("getUser", userId),
|
||||
getUserLibrary: (userId: string, take?: number, skip?: number) =>
|
||||
ipcRenderer.invoke("getUserLibrary", userId, take, skip),
|
||||
blockUser: (userId: string) => ipcRenderer.invoke("blockUser", userId),
|
||||
unblockUser: (userId: string) => ipcRenderer.invoke("unblockUser", userId),
|
||||
getUserFriends: (userId: string, take: number, skip: number) =>
|
||||
|
||||
@@ -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<void>;
|
||||
getUserLibraryGames: () => Promise<void>;
|
||||
setSelectedBackgroundImage: React.Dispatch<React.SetStateAction<string>>;
|
||||
backgroundImage: string;
|
||||
badges: Badge[];
|
||||
libraryGames: UserGame[];
|
||||
pinnedGames: UserGame[];
|
||||
}
|
||||
|
||||
export const DEFAULT_USER_PROFILE_BACKGROUND = "#151515B3";
|
||||
@@ -27,9 +30,12 @@ export const userProfileContext = createContext<UserProfileContext>({
|
||||
isMe: false,
|
||||
userStats: null,
|
||||
getUserProfile: async () => {},
|
||||
getUserLibraryGames: async () => {},
|
||||
setSelectedBackgroundImage: () => {},
|
||||
backgroundImage: "",
|
||||
badges: [],
|
||||
libraryGames: [],
|
||||
pinnedGames: [],
|
||||
});
|
||||
|
||||
const { Provider } = userProfileContext;
|
||||
@@ -49,6 +55,8 @@ export function UserProfileContextProvider({
|
||||
const [userStats, setUserStats] = useState<UserStats | null>(null);
|
||||
|
||||
const [userProfile, setUserProfile] = useState<UserProfile | null>(null);
|
||||
const [libraryGames, setLibraryGames] = useState<UserGame[]>([]);
|
||||
const [pinnedGames, setPinnedGames] = useState<UserGame[]>([]);
|
||||
const [badges, setBadges] = useState<Badge[]>([]);
|
||||
const [heroBackground, setHeroBackground] = useState(
|
||||
DEFAULT_USER_PROFILE_BACKGROUND
|
||||
@@ -85,8 +93,25 @@ export function UserProfileContextProvider({
|
||||
});
|
||||
}, [userId]);
|
||||
|
||||
const getUserLibraryGames = useCallback(async () => {
|
||||
try {
|
||||
const response = await window.electron.getUserLibrary(userId);
|
||||
if (response) {
|
||||
setLibraryGames(response.library);
|
||||
setPinnedGames(response.pinnedGames);
|
||||
} else {
|
||||
setLibraryGames([]);
|
||||
setPinnedGames([]);
|
||||
}
|
||||
} catch (error) {
|
||||
setLibraryGames([]);
|
||||
setPinnedGames([]);
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
const getUserProfile = useCallback(async () => {
|
||||
getUserStats();
|
||||
getUserLibraryGames();
|
||||
|
||||
return window.electron.getUser(userId).then((userProfile) => {
|
||||
if (userProfile) {
|
||||
@@ -102,7 +127,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 +136,8 @@ export function UserProfileContextProvider({
|
||||
|
||||
useEffect(() => {
|
||||
setUserProfile(null);
|
||||
setLibraryGames([]);
|
||||
setPinnedGames([]);
|
||||
setHeroBackground(DEFAULT_USER_PROFILE_BACKGROUND);
|
||||
|
||||
getUserProfile();
|
||||
@@ -124,10 +151,13 @@ export function UserProfileContextProvider({
|
||||
heroBackground,
|
||||
isMe,
|
||||
getUserProfile,
|
||||
getUserLibraryGames,
|
||||
setSelectedBackgroundImage,
|
||||
backgroundImage: getBackgroundImageUrl(),
|
||||
userStats,
|
||||
badges,
|
||||
libraryGames,
|
||||
pinnedGames,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
13
src/renderer/src/declaration.d.ts
vendored
13
src/renderer/src/declaration.d.ts
vendored
@@ -37,6 +37,7 @@ import type {
|
||||
ShopDetailsWithAssets,
|
||||
AchievementCustomNotificationPosition,
|
||||
AchievementNotificationInfo,
|
||||
UserLibraryResponse,
|
||||
} from "@types";
|
||||
import type { AxiosProgressEvent } from "axios";
|
||||
import type disk from "diskusage";
|
||||
@@ -126,6 +127,8 @@ declare global {
|
||||
shop: GameShop,
|
||||
objectId: string
|
||||
) => Promise<void>;
|
||||
addGameToPinned: (shop: GameShop, objectId: string) => Promise<void>;
|
||||
removeGameFromPinned: (shop: GameShop, objectId: string) => Promise<void>;
|
||||
updateLaunchOptions: (
|
||||
shop: GameShop,
|
||||
objectId: string,
|
||||
@@ -162,6 +165,11 @@ declare global {
|
||||
) => () => Electron.IpcRenderer;
|
||||
onLibraryBatchComplete: (cb: () => void) => () => Electron.IpcRenderer;
|
||||
resetGameAchievements: (shop: GameShop, objectId: string) => Promise<void>;
|
||||
changeGamePlayTime: (
|
||||
shop: GameShop,
|
||||
objectId: string,
|
||||
playtimeInSeconds: number
|
||||
) => Promise<void>;
|
||||
/* User preferences */
|
||||
authenticateRealDebrid: (apiToken: string) => Promise<RealDebridUser>;
|
||||
authenticateTorBox: (apiToken: string) => Promise<TorBoxUser>;
|
||||
@@ -282,6 +290,11 @@ declare global {
|
||||
|
||||
/* User */
|
||||
getUser: (userId: string) => Promise<UserProfile | null>;
|
||||
getUserLibrary: (
|
||||
userId: string,
|
||||
take?: number,
|
||||
skip?: number
|
||||
) => Promise<UserLibraryResponse>;
|
||||
blockUser: (userId: string) => Promise<void>;
|
||||
unblockUser: (userId: string) => Promise<void>;
|
||||
getUserFriends: (
|
||||
|
||||
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,
|
||||
HeartFillIcon,
|
||||
HeartIcon,
|
||||
PinIcon,
|
||||
PinSlashIcon,
|
||||
PlayIcon,
|
||||
PlusCircleIcon,
|
||||
} from "@primer/octicons-react";
|
||||
import { Button } from "@renderer/components";
|
||||
import { useDownload, useLibrary, useToast } from "@renderer/hooks";
|
||||
import {
|
||||
useDownload,
|
||||
useLibrary,
|
||||
useToast,
|
||||
useUserDetails,
|
||||
} from "@renderer/hooks";
|
||||
import { useContext, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { gameDetailsContext } from "@renderer/context";
|
||||
@@ -19,6 +26,7 @@ export function HeroPanelActions() {
|
||||
useState(false);
|
||||
|
||||
const { isGameDeleting } = useDownload();
|
||||
const { userDetails } = useUserDetails();
|
||||
|
||||
const {
|
||||
game,
|
||||
@@ -82,6 +90,29 @@ export function HeroPanelActions() {
|
||||
}
|
||||
};
|
||||
|
||||
const toggleGamePinned = async () => {
|
||||
setToggleLibraryGameDisabled(true);
|
||||
|
||||
try {
|
||||
if (game?.pinned && objectId) {
|
||||
await window.electron.removeGameFromPinned(shop, objectId).then(() => {
|
||||
showSuccessToast(t("game_removed_from_pinned"));
|
||||
});
|
||||
} else {
|
||||
if (!objectId) return;
|
||||
|
||||
await window.electron.addGameToPinned(shop, objectId).then(() => {
|
||||
showSuccessToast(t("game_added_to_pinned"));
|
||||
});
|
||||
}
|
||||
|
||||
updateLibrary();
|
||||
updateGame();
|
||||
} finally {
|
||||
setToggleLibraryGameDisabled(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openGame = async () => {
|
||||
if (game) {
|
||||
if (game.executablePath) {
|
||||
@@ -198,6 +229,17 @@ export function HeroPanelActions() {
|
||||
{game.favorite ? <HeartFillIcon /> : <HeartIcon />}
|
||||
</Button>
|
||||
|
||||
{userDetails && (
|
||||
<Button
|
||||
onClick={toggleGamePinned}
|
||||
theme="outline"
|
||||
disabled={deleting}
|
||||
className="hero-panel-actions__action"
|
||||
>
|
||||
{game.pinned ? <PinSlashIcon /> : <PinIcon />}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={() => setShowGameOptionsModal(true)}
|
||||
theme="outline"
|
||||
|
||||
@@ -12,4 +12,15 @@
|
||||
color: globals.$body-color;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
&__play-time {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__manual-warning {
|
||||
color: #f59e0b; // Warning amber color
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,13 @@ import { useDate, useDownload, useFormat } from "@renderer/hooks";
|
||||
import { Link } from "@renderer/components";
|
||||
import { gameDetailsContext } from "@renderer/context";
|
||||
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
|
||||
import { AlertFillIcon } from "@primer/octicons-react";
|
||||
import { Tooltip } from "react-tooltip";
|
||||
import "./hero-panel-playtime.scss";
|
||||
|
||||
export function HeroPanelPlaytime() {
|
||||
const [lastTimePlayed, setLastTimePlayed] = useState("");
|
||||
|
||||
const { game, isGameRunning } = useContext(gameDetailsContext);
|
||||
const { t } = useTranslation("game_details");
|
||||
const { numberFormatter } = useFormat();
|
||||
@@ -85,7 +88,26 @@ export function HeroPanelPlaytime() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<p>
|
||||
<p
|
||||
className="hero-panel-playtime__play-time"
|
||||
data-tooltip-place="top"
|
||||
data-tooltip-content={
|
||||
game.hasManuallyUpdatedPlaytime
|
||||
? t("manual_playtime_tooltip")
|
||||
: undefined
|
||||
}
|
||||
data-tooltip-id={
|
||||
game.hasManuallyUpdatedPlaytime
|
||||
? "manual-playtime-warning"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{game.hasManuallyUpdatedPlaytime && (
|
||||
<AlertFillIcon
|
||||
size={16}
|
||||
className="hero-panel-playtime__manual-warning"
|
||||
/>
|
||||
)}
|
||||
{t("play_time", {
|
||||
amount: formattedPlayTime,
|
||||
})}
|
||||
@@ -100,6 +122,16 @@ export function HeroPanelPlaytime() {
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{game.hasManuallyUpdatedPlaytime && (
|
||||
<Tooltip
|
||||
id="manual-playtime-warning"
|
||||
style={{
|
||||
zIndex: 9999,
|
||||
}}
|
||||
openOnClick={false}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
@use "../../../scss/globals.scss";
|
||||
|
||||
.change-game-playtime-modal {
|
||||
&__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: globals.$spacing-unit * 2;
|
||||
}
|
||||
|
||||
&__warning {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: globals.$spacing-unit;
|
||||
padding: globals.$spacing-unit;
|
||||
background-color: rgba(255, 193, 7, 0.1);
|
||||
border: 1px solid rgba(255, 193, 7, 0.3);
|
||||
border-radius: 4px;
|
||||
color: #ffc107;
|
||||
font-size: 14px;
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__inputs {
|
||||
display: flex;
|
||||
gap: globals.$spacing-unit;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: globals.$spacing-unit;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Modal, TextField } from "@renderer/components";
|
||||
import type { Game } from "@types";
|
||||
import { useState, useEffect } from "react";
|
||||
import { AlertIcon } from "@primer/octicons-react";
|
||||
import "./change-game-playtime-modal.scss";
|
||||
|
||||
export interface ChangeGamePlaytimeModalProps {
|
||||
visible: boolean;
|
||||
game: Game;
|
||||
onClose: () => void;
|
||||
changePlaytime: (playTimeInSeconds: number) => Promise<void>;
|
||||
onSuccess?: (message: string) => void;
|
||||
onError?: (message: string) => void;
|
||||
}
|
||||
|
||||
export function ChangeGamePlaytimeModal({
|
||||
onClose,
|
||||
game,
|
||||
visible,
|
||||
changePlaytime,
|
||||
onSuccess,
|
||||
onError,
|
||||
}: Readonly<ChangeGamePlaytimeModalProps>) {
|
||||
const { t } = useTranslation("game_details");
|
||||
const [hours, setHours] = useState("");
|
||||
const [minutes, setMinutes] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && game.playTimeInMilliseconds) {
|
||||
const totalMinutes = Math.floor(
|
||||
game.playTimeInMilliseconds / (1000 * 60)
|
||||
);
|
||||
const currentHours = Math.floor(totalMinutes / 60);
|
||||
const currentMinutes = totalMinutes % 60;
|
||||
|
||||
setHours(currentHours.toString());
|
||||
setMinutes(currentMinutes.toString());
|
||||
} else if (visible) {
|
||||
setHours("");
|
||||
setMinutes("");
|
||||
}
|
||||
}, [visible, game.playTimeInMilliseconds]);
|
||||
|
||||
const MAX_TOTAL_HOURS = 10000;
|
||||
|
||||
const currentHours = parseInt(hours) || 0;
|
||||
const currentMinutes = parseInt(minutes) || 0;
|
||||
|
||||
const maxAllowedHours = Math.min(
|
||||
MAX_TOTAL_HOURS,
|
||||
Math.floor(MAX_TOTAL_HOURS - currentMinutes / 60)
|
||||
);
|
||||
const maxAllowedMinutes =
|
||||
currentHours >= MAX_TOTAL_HOURS
|
||||
? 0
|
||||
: Math.min(59, Math.floor((MAX_TOTAL_HOURS - currentHours) * 60));
|
||||
|
||||
const handleChangePlaytime = async () => {
|
||||
const hoursNum = parseInt(hours) || 0;
|
||||
const minutesNum = parseInt(minutes) || 0;
|
||||
const totalSeconds = hoursNum * 3600 + minutesNum * 60;
|
||||
|
||||
if (totalSeconds < 0) return;
|
||||
|
||||
if (hoursNum + minutesNum / 60 > MAX_TOTAL_HOURS) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await changePlaytime(totalSeconds);
|
||||
onSuccess?.(t("update_playtime_success"));
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
onError?.(t("update_playtime_error"));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleHoursChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
let value = e.target.value;
|
||||
|
||||
if (value.length > 1 && value.startsWith("0")) {
|
||||
value = value.replace(/^0+/, "") || "0";
|
||||
}
|
||||
|
||||
const numValue = parseInt(value) || 0;
|
||||
|
||||
if (numValue <= maxAllowedHours) {
|
||||
setHours(value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMinutesChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
let value = e.target.value;
|
||||
|
||||
if (value.length > 1 && value.startsWith("0")) {
|
||||
value = value.replace(/^0+/, "") || "0";
|
||||
}
|
||||
|
||||
const numValue = parseInt(value) || 0;
|
||||
|
||||
if (numValue <= maxAllowedMinutes) {
|
||||
setMinutes(value);
|
||||
}
|
||||
};
|
||||
|
||||
const isValid = hours !== "" || minutes !== "";
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
title={t("update_playtime_title")}
|
||||
description={t("update_playtime_description", {
|
||||
game: game.title,
|
||||
})}
|
||||
>
|
||||
<div className="change-game-playtime-modal__content">
|
||||
{!game.hasManuallyUpdatedPlaytime && (
|
||||
<div className="change-game-playtime-modal__warning">
|
||||
<AlertIcon size={16} />
|
||||
<span>{t("manual_playtime_warning")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="change-game-playtime-modal__inputs">
|
||||
<TextField
|
||||
label={t("hours")}
|
||||
type="number"
|
||||
min="0"
|
||||
max={maxAllowedHours.toString()}
|
||||
value={hours}
|
||||
onChange={handleHoursChange}
|
||||
placeholder="0"
|
||||
theme="dark"
|
||||
/>
|
||||
<TextField
|
||||
label={t("minutes")}
|
||||
type="number"
|
||||
min="0"
|
||||
max={maxAllowedMinutes.toString()}
|
||||
value={minutes}
|
||||
onChange={handleMinutesChange}
|
||||
placeholder="0"
|
||||
theme="dark"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="change-game-playtime-modal__actions">
|
||||
<Button
|
||||
onClick={handleChangePlaytime}
|
||||
theme="outline"
|
||||
disabled={!isValid || isSubmitting}
|
||||
>
|
||||
{t("update_playtime")}
|
||||
</Button>
|
||||
|
||||
<Button onClick={onClose} theme="primary">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { DeleteGameModal } from "@renderer/pages/downloads/delete-game-modal";
|
||||
import { useDownload, useToast, useUserDetails } from "@renderer/hooks";
|
||||
import { RemoveGameFromLibraryModal } from "./remove-from-library-modal";
|
||||
import { ResetAchievementsModal } from "./reset-achievements-modal";
|
||||
import { ChangeGamePlaytimeModal } from "./change-game-playtime-modal";
|
||||
import { FileDirectoryIcon, FileIcon } from "@primer/octicons-react";
|
||||
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
|
||||
import { debounce } from "lodash-es";
|
||||
@@ -43,6 +44,7 @@ export function GameOptionsModal({
|
||||
const [launchOptions, setLaunchOptions] = useState(game.launchOptions ?? "");
|
||||
const [showResetAchievementsModal, setShowResetAchievementsModal] =
|
||||
useState(false);
|
||||
const [showChangePlaytimeModal, setShowChangePlaytimeModal] = useState(false);
|
||||
const [isDeletingAchievements, setIsDeletingAchievements] = useState(false);
|
||||
const [automaticCloudSync, setAutomaticCloudSync] = useState(
|
||||
game.automaticCloudSync ?? false
|
||||
@@ -228,6 +230,20 @@ export function GameOptionsModal({
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangePlaytime = async (playtimeInSeconds: number) => {
|
||||
try {
|
||||
await window.electron.changeGamePlayTime(
|
||||
game.shop,
|
||||
game.objectId,
|
||||
playtimeInSeconds
|
||||
);
|
||||
await updateGame();
|
||||
showSuccessToast(t("update_playtime_success"));
|
||||
} catch (error) {
|
||||
showErrorToast(t("update_playtime_error"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleAutomaticCloudSync = async (
|
||||
event: React.ChangeEvent<HTMLInputElement>
|
||||
) => {
|
||||
@@ -264,6 +280,13 @@ export function GameOptionsModal({
|
||||
game={game}
|
||||
/>
|
||||
|
||||
<ChangeGamePlaytimeModal
|
||||
visible={showChangePlaytimeModal}
|
||||
onClose={() => setShowChangePlaytimeModal(false)}
|
||||
changePlaytime={handleChangePlaytime}
|
||||
game={game}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
visible={visible}
|
||||
title={game.title}
|
||||
@@ -476,6 +499,13 @@ export function GameOptionsModal({
|
||||
{t("reset_achievements")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => setShowChangePlaytimeModal(true)}
|
||||
theme="danger"
|
||||
>
|
||||
{t("update_game_playtime")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowDeleteModal(true);
|
||||
|
||||
@@ -54,8 +54,66 @@
|
||||
&__section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
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-count {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
&__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 {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ProfileHero } from "../profile-hero/profile-hero";
|
||||
import { useAppDispatch, useFormat } from "@renderer/hooks";
|
||||
import { setHeaderTitle } from "@renderer/features";
|
||||
import { TelescopeIcon } from "@primer/octicons-react";
|
||||
import { TelescopeIcon, ChevronRightIcon } from "@primer/octicons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LockedProfile } from "./locked-profile";
|
||||
import { ReportProfile } from "../report-profile/report-profile";
|
||||
@@ -11,15 +11,80 @@ import { FriendsBox } from "./friends-box";
|
||||
import { RecentGamesBox } from "./recent-games-box";
|
||||
import { UserStatsBox } from "./user-stats-box";
|
||||
import { UserLibraryGameCard } from "./user-library-game-card";
|
||||
import { useSectionCollapse } from "@renderer/hooks/use-section-collapse";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import "./profile-content.scss";
|
||||
|
||||
const GAME_STATS_ANIMATION_DURATION_IN_MS = 3500;
|
||||
|
||||
const sectionVariants = {
|
||||
collapsed: {
|
||||
opacity: 0,
|
||||
y: -20,
|
||||
height: 0,
|
||||
transition: {
|
||||
duration: 0.3,
|
||||
ease: [0.25, 0.1, 0.25, 1],
|
||||
opacity: { duration: 0.1 },
|
||||
y: { duration: 0.1 },
|
||||
height: { duration: 0.2 },
|
||||
},
|
||||
},
|
||||
expanded: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
height: "auto",
|
||||
transition: {
|
||||
duration: 0.3,
|
||||
ease: [0.25, 0.1, 0.25, 1],
|
||||
opacity: { duration: 0.2, delay: 0.1 },
|
||||
y: { duration: 0.3 },
|
||||
height: { duration: 0.3 },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const gameCardVariants = {
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
y: 20,
|
||||
scale: 0.95,
|
||||
},
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
transition: {
|
||||
duration: 0.4,
|
||||
ease: [0.25, 0.1, 0.25, 1],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const chevronVariants = {
|
||||
collapsed: {
|
||||
rotate: 0,
|
||||
transition: {
|
||||
duration: 0.2,
|
||||
ease: "easeInOut",
|
||||
},
|
||||
},
|
||||
expanded: {
|
||||
rotate: 90,
|
||||
transition: {
|
||||
duration: 0.2,
|
||||
ease: "easeInOut",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export function ProfileContent() {
|
||||
const { userProfile, isMe, userStats } = useContext(userProfileContext);
|
||||
const { userProfile, isMe, userStats, libraryGames, pinnedGames } =
|
||||
useContext(userProfileContext);
|
||||
const [statsIndex, setStatsIndex] = useState(0);
|
||||
const [isAnimationRunning, setIsAnimationRunning] = useState(true);
|
||||
const statsAnimation = useRef(-1);
|
||||
const { toggleSection, isPinnedCollapsed } = useSectionCollapse();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
@@ -79,14 +144,17 @@ export function ProfileContent() {
|
||||
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 (
|
||||
<section className="profile-content__section">
|
||||
<div className="profile-content__main">
|
||||
{!hasGames && (
|
||||
{!hasAnyGames && (
|
||||
<div className="profile-content__no-games">
|
||||
<div className="profile-content__telescope-icon">
|
||||
<TelescopeIcon size={24} />
|
||||
@@ -96,28 +164,106 @@ export function ProfileContent() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasGames && (
|
||||
<>
|
||||
<div className="profile-content__section-header">
|
||||
<h2>{t("library")}</h2>
|
||||
{hasAnyGames && (
|
||||
<div>
|
||||
{hasPinnedGames && (
|
||||
<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>
|
||||
</div>
|
||||
<span className="profile-content__section-count">
|
||||
{pinnedGames.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{userStats && (
|
||||
<span>{numberFormatter.format(userStats.libraryCount)}</span>
|
||||
)}
|
||||
</div>
|
||||
<AnimatePresence initial={true} mode="wait">
|
||||
{!isPinnedCollapsed && (
|
||||
<motion.div
|
||||
key="pinned-content"
|
||||
variants={sectionVariants}
|
||||
initial="collapsed"
|
||||
animate="expanded"
|
||||
exit="collapsed"
|
||||
layout
|
||||
>
|
||||
<ul className="profile-content__games-grid">
|
||||
{pinnedGames?.map((game, index) => (
|
||||
<motion.li
|
||||
key={game.objectId}
|
||||
variants={gameCardVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
transition={{ delay: index * 0.1 }}
|
||||
style={{ listStyle: "none" }}
|
||||
>
|
||||
<UserLibraryGameCard
|
||||
game={game}
|
||||
statIndex={statsIndex}
|
||||
onMouseEnter={handleOnMouseEnterGameCard}
|
||||
onMouseLeave={handleOnMouseLeaveGameCard}
|
||||
/>
|
||||
</motion.li>
|
||||
))}
|
||||
</ul>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ul className="profile-content__games-grid">
|
||||
{userProfile?.libraryGames?.map((game) => (
|
||||
<UserLibraryGameCard
|
||||
game={game}
|
||||
key={game.objectId}
|
||||
statIndex={statsIndex}
|
||||
onMouseEnter={handleOnMouseEnterGameCard}
|
||||
onMouseLeave={handleOnMouseLeaveGameCard}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
{hasGames && (
|
||||
<div>
|
||||
<div className="profile-content__section-header">
|
||||
<div className="profile-content__section-title-group">
|
||||
<h2>{t("library")}</h2>
|
||||
</div>
|
||||
{userStats && (
|
||||
<span className="profile-content__section-count">
|
||||
{numberFormatter.format(userStats.libraryCount)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ul className="profile-content__games-grid">
|
||||
{libraryGames?.map((game, index) => (
|
||||
<motion.li
|
||||
key={game.objectId}
|
||||
variants={gameCardVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
transition={{ delay: index * 0.1 }}
|
||||
style={{ listStyle: "none" }}
|
||||
>
|
||||
<UserLibraryGameCard
|
||||
game={game}
|
||||
statIndex={statsIndex}
|
||||
onMouseEnter={handleOnMouseEnterGameCard}
|
||||
onMouseLeave={handleOnMouseLeaveGameCard}
|
||||
/>
|
||||
</motion.li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -139,6 +285,10 @@ export function ProfileContent() {
|
||||
numberFormatter,
|
||||
t,
|
||||
statsIndex,
|
||||
libraryGames,
|
||||
pinnedGames,
|
||||
isPinnedCollapsed,
|
||||
toggleSection,
|
||||
]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -65,6 +65,47 @@
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
&__actions-container {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
&__favorite-icon {
|
||||
color: white;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
border-radius: 50%;
|
||||
padding: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&__pin-button {
|
||||
color: white;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
padding: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
&__playtime {
|
||||
background-color: globals.$background-color;
|
||||
color: globals.$muted-color;
|
||||
@@ -75,7 +116,9 @@
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
&__manual-playtime {
|
||||
color: globals.$warning-color;
|
||||
}
|
||||
&__stats {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
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 } from "react";
|
||||
import { useCallback, useContext, useState } from "react";
|
||||
import {
|
||||
buildGameAchievementPath,
|
||||
buildGameDetailsPath,
|
||||
formatDownloadProgress,
|
||||
} from "@renderer/helpers";
|
||||
import { userProfileContext } from "@renderer/context";
|
||||
import { ClockIcon, TrophyIcon } 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";
|
||||
import "./user-library-game-card.scss";
|
||||
|
||||
@@ -27,10 +35,15 @@ export function UserLibraryGameCard({
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
}: UserLibraryGameCardProps) {
|
||||
const { userProfile } = useContext(userProfileContext);
|
||||
const { userProfile, isMe, getUserLibraryGames } =
|
||||
useContext(userProfileContext);
|
||||
const { t } = useTranslation("user_profile");
|
||||
const { t: tGame } = useTranslation("game_details");
|
||||
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;
|
||||
@@ -82,78 +95,161 @@ export function UserLibraryGameCard({
|
||||
[numberFormatter, t]
|
||||
);
|
||||
|
||||
return (
|
||||
<li
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
className="user-library-game__wrapper"
|
||||
title={game.title}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="user-library-game__cover"
|
||||
onClick={() => navigate(buildUserGameDetailsPath(game))}
|
||||
>
|
||||
<div className="user-library-game__overlay">
|
||||
<small className="user-library-game__playtime">
|
||||
<ClockIcon size={11} />
|
||||
{formatPlayTime(game.playTimeInSeconds)}
|
||||
</small>
|
||||
const toggleGamePinned = async () => {
|
||||
setIsPinning(true);
|
||||
|
||||
{userProfile?.hasActiveSubscription && game.achievementCount > 0 && (
|
||||
<div className="user-library-game__stats">
|
||||
<div className="user-library-game__stats-header">
|
||||
<div className="user-library-game__stats-content">
|
||||
<div
|
||||
className="user-library-game__stats-item"
|
||||
style={{
|
||||
transform: `translateY(${-100 * (statIndex % getStatsItemCount())}%)`,
|
||||
try {
|
||||
if (game.isPinned) {
|
||||
await window.electron
|
||||
.removeGameFromPinned(game.shop, game.objectId)
|
||||
.then(() => {
|
||||
showSuccessToast(tGame("game_removed_from_pinned"));
|
||||
});
|
||||
} else {
|
||||
await window.electron
|
||||
.addGameToPinned(game.shop, game.objectId)
|
||||
.then(() => {
|
||||
showSuccessToast(tGame("game_added_to_pinned"));
|
||||
});
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
await getUserLibraryGames();
|
||||
} finally {
|
||||
setIsPinning(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<li
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
className="user-library-game__wrapper"
|
||||
title={isTooltipHovered ? undefined : game.title}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="user-library-game__cover"
|
||||
onClick={() => navigate(buildUserGameDetailsPath(game))}
|
||||
>
|
||||
<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}
|
||||
>
|
||||
<TrophyIcon size={13} />
|
||||
{game.isPinned ? (
|
||||
<PinSlashIcon size={12} />
|
||||
) : (
|
||||
<PinIcon size={12} />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<small
|
||||
className="user-library-game__playtime"
|
||||
data-tooltip-place="top"
|
||||
data-tooltip-content={
|
||||
game.hasManuallyUpdatedPlaytime
|
||||
? t("manual_playtime_tooltip")
|
||||
: undefined
|
||||
}
|
||||
data-tooltip-id={game.objectId}
|
||||
>
|
||||
{game.hasManuallyUpdatedPlaytime ? (
|
||||
<AlertFillIcon
|
||||
size={11}
|
||||
className="user-library-game__manual-playtime"
|
||||
/>
|
||||
) : (
|
||||
<ClockIcon size={11} />
|
||||
)}
|
||||
{formatPlayTime(game.playTimeInSeconds)}
|
||||
</small>
|
||||
|
||||
{userProfile?.hasActiveSubscription &&
|
||||
game.achievementCount > 0 && (
|
||||
<div className="user-library-game__stats">
|
||||
<div className="user-library-game__stats-header">
|
||||
<div className="user-library-game__stats-content">
|
||||
<div
|
||||
className="user-library-game__stats-item"
|
||||
style={{
|
||||
transform: `translateY(${-100 * (statIndex % getStatsItemCount())}%)`,
|
||||
}}
|
||||
>
|
||||
<TrophyIcon size={13} />
|
||||
<span>
|
||||
{game.unlockedAchievementCount} /{" "}
|
||||
{game.achievementCount}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{game.achievementsPointsEarnedSum > 0 && (
|
||||
<div
|
||||
className="user-library-game__stats-item"
|
||||
style={{
|
||||
transform: `translateY(${-100 * (statIndex % getStatsItemCount())}%)`,
|
||||
}}
|
||||
>
|
||||
<HydraIcon width={16} height={16} />
|
||||
{formatAchievementPoints(
|
||||
game.achievementsPointsEarnedSum
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span>
|
||||
{game.unlockedAchievementCount} / {game.achievementCount}
|
||||
{formatDownloadProgress(
|
||||
game.unlockedAchievementCount / game.achievementCount,
|
||||
1
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{game.achievementsPointsEarnedSum > 0 && (
|
||||
<div
|
||||
className="user-library-game__stats-item"
|
||||
style={{
|
||||
transform: `translateY(${-100 * (statIndex % getStatsItemCount())}%)`,
|
||||
}}
|
||||
>
|
||||
<HydraIcon width={16} height={16} />
|
||||
{formatAchievementPoints(
|
||||
game.achievementsPointsEarnedSum
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<progress
|
||||
max={1}
|
||||
value={
|
||||
game.unlockedAchievementCount / game.achievementCount
|
||||
}
|
||||
className="user-library-game__achievements-progress"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span>
|
||||
{formatDownloadProgress(
|
||||
game.unlockedAchievementCount / game.achievementCount,
|
||||
1
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<progress
|
||||
max={1}
|
||||
value={game.unlockedAchievementCount / game.achievementCount}
|
||||
className="user-library-game__achievements-progress"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<img
|
||||
src={game.coverImageUrl}
|
||||
alt={game.title}
|
||||
className="user-library-game__game-image"
|
||||
/>
|
||||
</button>
|
||||
</li>
|
||||
<img
|
||||
src={game.coverImageUrl}
|
||||
alt={game.title}
|
||||
className="user-library-game__game-image"
|
||||
/>
|
||||
</button>
|
||||
</li>
|
||||
<Tooltip
|
||||
id={game.objectId}
|
||||
style={{
|
||||
zIndex: 9999,
|
||||
}}
|
||||
openOnClick={false}
|
||||
afterShow={() => setIsTooltipHovered(true)}
|
||||
afterHide={() => setIsTooltipHovered(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -70,8 +70,18 @@ export type UserGame = {
|
||||
unlockedAchievementCount: number;
|
||||
achievementCount: number;
|
||||
achievementsPointsEarnedSum: number;
|
||||
hasManuallyUpdatedPlaytime: boolean;
|
||||
isFavorite: boolean;
|
||||
isPinned: boolean;
|
||||
pinnedDate?: Date | null;
|
||||
} & ShopAssets;
|
||||
|
||||
export interface UserLibraryResponse {
|
||||
totalCount: number;
|
||||
library: UserGame[];
|
||||
pinnedGames: UserGame[];
|
||||
}
|
||||
|
||||
export interface GameRunning {
|
||||
id: string;
|
||||
title: string;
|
||||
|
||||
@@ -44,7 +44,10 @@ export interface Game {
|
||||
executablePath?: string | null;
|
||||
launchOptions?: string | null;
|
||||
favorite?: boolean;
|
||||
pinned?: boolean;
|
||||
pinnedDate?: Date | null;
|
||||
automaticCloudSync?: boolean;
|
||||
hasManuallyUpdatedPlaytime?: boolean;
|
||||
}
|
||||
|
||||
export interface Download {
|
||||
|
||||
Reference in New Issue
Block a user