mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-11 05:46:17 +00:00
Merge pull request #1786 from hydralauncher/feat/custom-games-support
Feat: Custom Games Support + Edit game info support
This commit is contained in:
@@ -27,7 +27,50 @@
|
||||
"friends": "Friends",
|
||||
"need_help": "Need help?",
|
||||
"favorites": "Favorites",
|
||||
"playable_button_title": "Show only games you can play now"
|
||||
"playable_button_title": "Show only games you can play now",
|
||||
"add_custom_game_tooltip": "Add Custom Game",
|
||||
"show_playable_only_tooltip": "Show Playable Only",
|
||||
"custom_game_modal": "Add Custom Game",
|
||||
"custom_game_modal_description": "Add a custom game to your library by selecting an executable file",
|
||||
"custom_game_modal_executable_path": "Executable Path",
|
||||
"custom_game_modal_select_executable": "Select executable file",
|
||||
"custom_game_modal_title": "Title",
|
||||
"custom_game_modal_enter_title": "Enter title",
|
||||
"custom_game_modal_browse": "Browse",
|
||||
"custom_game_modal_cancel": "Cancel",
|
||||
"custom_game_modal_add": "Add Game",
|
||||
"custom_game_modal_adding": "Adding Game...",
|
||||
"custom_game_modal_success": "Custom game added successfully",
|
||||
"custom_game_modal_failed": "Failed to add custom game",
|
||||
"custom_game_modal_executable": "Executable",
|
||||
"edit_game_modal": "Customize Assets",
|
||||
"edit_game_modal_description": "Customize game assets and details",
|
||||
"edit_game_modal_title": "Title",
|
||||
"edit_game_modal_enter_title": "Enter title",
|
||||
"edit_game_modal_image": "Image",
|
||||
"edit_game_modal_select_image": "Select image",
|
||||
"edit_game_modal_browse": "Browse",
|
||||
"edit_game_modal_image_preview": "Image preview",
|
||||
"edit_game_modal_icon": "Icon",
|
||||
"edit_game_modal_select_icon": "Select icon",
|
||||
"edit_game_modal_icon_preview": "Icon preview",
|
||||
"edit_game_modal_logo": "Logo",
|
||||
"edit_game_modal_select_logo": "Select logo",
|
||||
"edit_game_modal_logo_preview": "Logo preview",
|
||||
"edit_game_modal_hero": "Library Hero",
|
||||
"edit_game_modal_select_hero": "Select library hero image",
|
||||
"edit_game_modal_hero_preview": "Library hero image preview",
|
||||
"edit_game_modal_cancel": "Cancel",
|
||||
"edit_game_modal_update": "Update",
|
||||
"edit_game_modal_updating": "Updating...",
|
||||
"edit_game_modal_fill_required": "Please fill in all required fields",
|
||||
"edit_game_modal_success": "Assets updated successfully",
|
||||
"edit_game_modal_failed": "Failed to update assets",
|
||||
"edit_game_modal_image_filter": "Image",
|
||||
"edit_game_modal_icon_resolution": "Recommended resolution: 256x256px",
|
||||
"edit_game_modal_logo_resolution": "Recommended resolution: 640x360px",
|
||||
"edit_game_modal_hero_resolution": "Recommended resolution: 1920x620px",
|
||||
"edit_game_modal_assets": "Assets"
|
||||
},
|
||||
"header": {
|
||||
"search": "Search games",
|
||||
@@ -230,6 +273,7 @@
|
||||
"backup_unfrozen": "Backup unpinned",
|
||||
"backup_freeze_failed": "Failed to freeze backup",
|
||||
"backup_freeze_failed_description": "You must leave at least one free slot for automatic backups",
|
||||
"edit_game_modal_button": "Customize game assets",
|
||||
"game_details": "Game Details",
|
||||
"currency_symbol": "$",
|
||||
"currency_country": "us",
|
||||
|
||||
@@ -29,7 +29,47 @@
|
||||
"friends": "Друзья",
|
||||
"need_help": "Нужна помощь?",
|
||||
"favorites": "Избранное",
|
||||
"playable_button_title": "Показать только игры, в которые можно играть сейчас"
|
||||
"playable_button_title": "Показать только установленные игры.",
|
||||
"custom_game_modal": "Добавить пользовательскую игру",
|
||||
"custom_game_modal_description": "Добавьте пользовательскую игру в библиотеку, выбрав исполняемый файл",
|
||||
"custom_game_modal_executable_path": "Путь к исполняемому файлу",
|
||||
"custom_game_modal_select_executable": "Выберите исполняемый файл",
|
||||
"custom_game_modal_title": "Название игры",
|
||||
"custom_game_modal_enter_title": "Введите название игры",
|
||||
"custom_game_modal_browse": "Обзор",
|
||||
"custom_game_modal_cancel": "Отмена",
|
||||
"custom_game_modal_add": "Добавить игру",
|
||||
"custom_game_modal_adding": "Добавление игры...",
|
||||
"custom_game_modal_success": "Пользовательская игра успешно добавлена",
|
||||
"custom_game_modal_failed": "Не удалось добавить пользовательскую игру",
|
||||
"custom_game_modal_executable": "Исполняемый файл",
|
||||
"edit_game_modal": "Настроить ресурсы",
|
||||
"edit_game_modal_description": "Настройте ресурсы и детали игры",
|
||||
"edit_game_modal_title": "Название",
|
||||
"edit_game_modal_enter_title": "Введите название",
|
||||
"edit_game_modal_image": "Изображение",
|
||||
"edit_game_modal_select_image": "Выберите изображение",
|
||||
"edit_game_modal_browse": "Обзор",
|
||||
"edit_game_modal_image_preview": "Предпросмотр изображения",
|
||||
"edit_game_modal_icon": "Иконка",
|
||||
"edit_game_modal_select_icon": "Выберите иконку",
|
||||
"edit_game_modal_icon_preview": "Предпросмотр иконки",
|
||||
"edit_game_modal_logo": "Логотип",
|
||||
"edit_game_modal_select_logo": "Выберите логотип",
|
||||
"edit_game_modal_logo_preview": "Предпросмотр логотипа",
|
||||
"edit_game_modal_hero": "Изображение обложку игры",
|
||||
"edit_game_modal_select_hero": "Выберите обложку игры",
|
||||
"edit_game_modal_hero_preview": "Предпросмотр обложки игры",
|
||||
"edit_game_modal_cancel": "Отмена",
|
||||
"edit_game_modal_update": "Обновить",
|
||||
"edit_game_modal_updating": "Обновление...",
|
||||
"edit_game_modal_fill_required": "Пожалуйста, заполните все обязательные поля",
|
||||
"edit_game_modal_success": "Ресурсы успешно обновлены",
|
||||
"edit_game_modal_failed": "Не удалось обновить ресурсы",
|
||||
"edit_game_modal_image_filter": "Изображение",
|
||||
"edit_game_modal_icon_resolution": "Рекомендуемое разрешение: 256x256px",
|
||||
"edit_game_modal_logo_resolution": "Рекомендуемое разрешение: 640x360px",
|
||||
"edit_game_modal_hero_resolution": "Рекомендуемое разрешение: 1920x620px"
|
||||
},
|
||||
"header": {
|
||||
"search": "Поиск",
|
||||
|
||||
@@ -10,7 +10,16 @@ const saveGameShopAssets = async (
|
||||
): Promise<void> => {
|
||||
const key = levelKeys.game(shop, objectId);
|
||||
const existingAssets = await gamesShopAssetsSublevel.get(key);
|
||||
return gamesShopAssetsSublevel.put(key, { ...existingAssets, ...assets });
|
||||
|
||||
// Preserve existing title if it differs from the incoming title (indicating it was customized)
|
||||
const shouldPreserveTitle =
|
||||
existingAssets?.title && existingAssets.title !== assets.title;
|
||||
|
||||
return gamesShopAssetsSublevel.put(key, {
|
||||
...existingAssets,
|
||||
...assets,
|
||||
title: shouldPreserveTitle ? existingAssets.title : assets.title,
|
||||
});
|
||||
};
|
||||
|
||||
registerEvent("saveGameShopAssets", saveGameShopAssets);
|
||||
|
||||
@@ -14,6 +14,9 @@ import "./catalogue/get-developers";
|
||||
import "./hardware/get-disk-free-space";
|
||||
import "./hardware/check-folder-write-permission";
|
||||
import "./library/add-game-to-library";
|
||||
import "./library/add-custom-game-to-library";
|
||||
import "./library/update-custom-game";
|
||||
import "./library/update-game-custom-assets";
|
||||
import "./library/add-game-to-favorites";
|
||||
import "./library/remove-game-from-favorites";
|
||||
import "./library/toggle-game-pin";
|
||||
@@ -37,7 +40,9 @@ 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/cleanup-unused-assets";
|
||||
import "./library/create-steam-shortcut";
|
||||
import "./library/copy-custom-game-asset";
|
||||
import "./misc/open-checkout";
|
||||
import "./misc/open-external";
|
||||
import "./misc/show-open-dialog";
|
||||
@@ -46,6 +51,8 @@ import "./misc/show-item-in-folder";
|
||||
import "./misc/get-badges";
|
||||
import "./misc/install-common-redist";
|
||||
import "./misc/can-install-common-redist";
|
||||
import "./misc/save-temp-file";
|
||||
import "./misc/delete-temp-file";
|
||||
import "./torrenting/cancel-game-download";
|
||||
import "./torrenting/pause-game-download";
|
||||
import "./torrenting/resume-game-download";
|
||||
|
||||
65
src/main/events/library/add-custom-game-to-library.ts
Normal file
65
src/main/events/library/add-custom-game-to-library.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { gamesSublevel, gamesShopAssetsSublevel, levelKeys } from "@main/level";
|
||||
import { randomUUID } from "crypto";
|
||||
import type { GameShop } from "@types";
|
||||
|
||||
const addCustomGameToLibrary = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
title: string,
|
||||
executablePath: string,
|
||||
iconUrl?: string,
|
||||
logoImageUrl?: string,
|
||||
libraryHeroImageUrl?: string
|
||||
) => {
|
||||
const objectId = randomUUID();
|
||||
const shop: GameShop = "custom";
|
||||
const gameKey = levelKeys.game(shop, objectId);
|
||||
|
||||
const existingGames = await gamesSublevel.iterator().all();
|
||||
const existingGame = existingGames.find(
|
||||
([_key, game]) => game.executablePath === executablePath && !game.isDeleted
|
||||
);
|
||||
|
||||
if (existingGame) {
|
||||
throw new Error(
|
||||
"A game with this executable path already exists in your library"
|
||||
);
|
||||
}
|
||||
|
||||
const assets = {
|
||||
objectId,
|
||||
shop,
|
||||
title,
|
||||
iconUrl: iconUrl || null,
|
||||
libraryHeroImageUrl: libraryHeroImageUrl || "",
|
||||
libraryImageUrl: iconUrl || "",
|
||||
logoImageUrl: logoImageUrl || "",
|
||||
logoPosition: null,
|
||||
coverImageUrl: iconUrl || "",
|
||||
};
|
||||
await gamesShopAssetsSublevel.put(gameKey, assets);
|
||||
|
||||
const game = {
|
||||
title,
|
||||
iconUrl: iconUrl || null,
|
||||
logoImageUrl: logoImageUrl || null,
|
||||
libraryHeroImageUrl: libraryHeroImageUrl || null,
|
||||
objectId,
|
||||
shop,
|
||||
remoteId: null,
|
||||
isDeleted: false,
|
||||
playTimeInMilliseconds: 0,
|
||||
lastTimePlayed: null,
|
||||
executablePath,
|
||||
launchOptions: null,
|
||||
favorite: false,
|
||||
automaticCloudSync: false,
|
||||
hasManuallyUpdatedPlaytime: false,
|
||||
};
|
||||
|
||||
await gamesSublevel.put(gameKey, game);
|
||||
|
||||
return game;
|
||||
};
|
||||
|
||||
registerEvent("addCustomGameToLibrary", addCustomGameToLibrary);
|
||||
@@ -30,6 +30,8 @@ const addGameToLibrary = async (
|
||||
game = {
|
||||
title,
|
||||
iconUrl: gameAssets?.iconUrl ?? null,
|
||||
libraryHeroImageUrl: gameAssets?.libraryHeroImageUrl ?? null,
|
||||
logoImageUrl: gameAssets?.logoImageUrl ?? null,
|
||||
objectId,
|
||||
shop,
|
||||
remoteId: null,
|
||||
@@ -41,12 +43,14 @@ const addGameToLibrary = async (
|
||||
await gamesSublevel.put(gameKey, game);
|
||||
}
|
||||
|
||||
await createGame(game).catch(() => {});
|
||||
if (game) {
|
||||
await createGame(game).catch(() => {});
|
||||
|
||||
AchievementWatcherManager.firstSyncWithRemoteIfNeeded(
|
||||
game.shop,
|
||||
game.objectId
|
||||
);
|
||||
AchievementWatcherManager.firstSyncWithRemoteIfNeeded(
|
||||
game.shop,
|
||||
game.objectId
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
registerEvent("addGameToLibrary", addGameToLibrary);
|
||||
|
||||
@@ -13,16 +13,20 @@ const changeGamePlaytime = async (
|
||||
const gameKey = levelKeys.game(shop, objectId);
|
||||
const game = await gamesSublevel.get(gameKey);
|
||||
if (!game) return;
|
||||
await HydraApi.put(`/profile/games/${shop}/${objectId}/playtime`, {
|
||||
playTimeInSeconds,
|
||||
});
|
||||
|
||||
if (game.remoteId) {
|
||||
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}`);
|
||||
throw new Error(`Failed to update game playtime: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
76
src/main/events/library/cleanup-unused-assets.ts
Normal file
76
src/main/events/library/cleanup-unused-assets.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { ipcMain } from "electron";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { ASSETS_PATH } from "@main/constants";
|
||||
|
||||
const getCustomGamesAssetsPath = () => {
|
||||
return path.join(ASSETS_PATH, "custom-games");
|
||||
};
|
||||
|
||||
const getAllCustomGameAssets = async (): Promise<string[]> => {
|
||||
const assetsPath = getCustomGamesAssetsPath();
|
||||
|
||||
if (!fs.existsSync(assetsPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const files = await fs.promises.readdir(assetsPath);
|
||||
return files.map((file) => path.join(assetsPath, file));
|
||||
};
|
||||
|
||||
const getUsedAssetPaths = async (): Promise<Set<string>> => {
|
||||
// Get all custom games from the level database
|
||||
const { gamesSublevel } = await import("@main/level");
|
||||
const allGames = await gamesSublevel.iterator().all();
|
||||
|
||||
const customGames = allGames
|
||||
.map(([_key, game]) => game)
|
||||
.filter((game) => game.shop === "custom" && !game.isDeleted);
|
||||
|
||||
const usedPaths = new Set<string>();
|
||||
|
||||
customGames.forEach((game) => {
|
||||
// Extract file paths from local URLs
|
||||
if (game.iconUrl?.startsWith("local:")) {
|
||||
usedPaths.add(game.iconUrl.replace("local:", ""));
|
||||
}
|
||||
if (game.logoImageUrl?.startsWith("local:")) {
|
||||
usedPaths.add(game.logoImageUrl.replace("local:", ""));
|
||||
}
|
||||
if (game.libraryHeroImageUrl?.startsWith("local:")) {
|
||||
usedPaths.add(game.libraryHeroImageUrl.replace("local:", ""));
|
||||
}
|
||||
});
|
||||
|
||||
return usedPaths;
|
||||
};
|
||||
|
||||
export const cleanupUnusedAssets = async (): Promise<{
|
||||
deletedCount: number;
|
||||
errors: string[];
|
||||
}> => {
|
||||
try {
|
||||
const allAssets = await getAllCustomGameAssets();
|
||||
const usedAssets = await getUsedAssetPaths();
|
||||
|
||||
const errors: string[] = [];
|
||||
let deletedCount = 0;
|
||||
|
||||
for (const assetPath of allAssets) {
|
||||
if (!usedAssets.has(assetPath)) {
|
||||
try {
|
||||
await fs.promises.unlink(assetPath);
|
||||
deletedCount++;
|
||||
} catch (error) {
|
||||
errors.push(`Failed to delete ${assetPath}: ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { deletedCount, errors };
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to cleanup unused assets: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
ipcMain.handle("cleanupUnusedAssets", cleanupUnusedAssets);
|
||||
42
src/main/events/library/copy-custom-game-asset.ts
Normal file
42
src/main/events/library/copy-custom-game-asset.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { randomUUID } from "crypto";
|
||||
import { ASSETS_PATH } from "@main/constants";
|
||||
|
||||
const copyCustomGameAsset = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
sourcePath: string,
|
||||
assetType: "icon" | "logo" | "hero"
|
||||
): Promise<string> => {
|
||||
if (!sourcePath || !fs.existsSync(sourcePath)) {
|
||||
throw new Error("Source file does not exist");
|
||||
}
|
||||
|
||||
// Ensure assets directory exists
|
||||
if (!fs.existsSync(ASSETS_PATH)) {
|
||||
fs.mkdirSync(ASSETS_PATH, { recursive: true });
|
||||
}
|
||||
|
||||
// Create custom games assets subdirectory
|
||||
const customGamesAssetsPath = path.join(ASSETS_PATH, "custom-games");
|
||||
if (!fs.existsSync(customGamesAssetsPath)) {
|
||||
fs.mkdirSync(customGamesAssetsPath, { recursive: true });
|
||||
}
|
||||
|
||||
// Get file extension
|
||||
const fileExtension = path.extname(sourcePath);
|
||||
|
||||
// Generate unique filename
|
||||
const uniqueId = randomUUID();
|
||||
const fileName = `${assetType}-${uniqueId}${fileExtension}`;
|
||||
const destinationPath = path.join(customGamesAssetsPath, fileName);
|
||||
|
||||
// Copy the file
|
||||
await fs.promises.copyFile(sourcePath, destinationPath);
|
||||
|
||||
// Return the local URL format
|
||||
return `local:${destinationPath}`;
|
||||
};
|
||||
|
||||
registerEvent("copyCustomGameAsset", copyCustomGameAsset);
|
||||
@@ -23,7 +23,10 @@ const getLibrary = async (): Promise<LibraryGame[]> => {
|
||||
...game,
|
||||
download: download ?? null,
|
||||
...gameAssets,
|
||||
};
|
||||
// Ensure compatibility with LibraryGame type
|
||||
libraryHeroImageUrl:
|
||||
game.libraryHeroImageUrl ?? gameAssets?.libraryHeroImageUrl,
|
||||
} as LibraryGame;
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
49
src/main/events/library/update-custom-game.ts
Normal file
49
src/main/events/library/update-custom-game.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { gamesSublevel, gamesShopAssetsSublevel, levelKeys } from "@main/level";
|
||||
import type { GameShop } from "@types";
|
||||
|
||||
const updateCustomGame = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
shop: GameShop,
|
||||
objectId: string,
|
||||
title: string,
|
||||
iconUrl?: string,
|
||||
logoImageUrl?: string,
|
||||
libraryHeroImageUrl?: string
|
||||
) => {
|
||||
const gameKey = levelKeys.game(shop, objectId);
|
||||
|
||||
const existingGame = await gamesSublevel.get(gameKey);
|
||||
if (!existingGame) {
|
||||
throw new Error("Game not found");
|
||||
}
|
||||
|
||||
const updatedGame = {
|
||||
...existingGame,
|
||||
title,
|
||||
iconUrl: iconUrl || null,
|
||||
logoImageUrl: logoImageUrl || null,
|
||||
libraryHeroImageUrl: libraryHeroImageUrl || null,
|
||||
};
|
||||
|
||||
await gamesSublevel.put(gameKey, updatedGame);
|
||||
|
||||
const existingAssets = await gamesShopAssetsSublevel.get(gameKey);
|
||||
if (existingAssets) {
|
||||
const updatedAssets = {
|
||||
...existingAssets,
|
||||
title,
|
||||
iconUrl: iconUrl || null,
|
||||
libraryHeroImageUrl: libraryHeroImageUrl || "",
|
||||
libraryImageUrl: iconUrl || "",
|
||||
logoImageUrl: logoImageUrl || "",
|
||||
coverImageUrl: iconUrl || "",
|
||||
};
|
||||
|
||||
await gamesShopAssetsSublevel.put(gameKey, updatedAssets);
|
||||
}
|
||||
|
||||
return updatedGame;
|
||||
};
|
||||
|
||||
registerEvent("updateCustomGame", updateCustomGame);
|
||||
45
src/main/events/library/update-game-custom-assets.ts
Normal file
45
src/main/events/library/update-game-custom-assets.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { gamesSublevel, gamesShopAssetsSublevel, levelKeys } from "@main/level";
|
||||
import type { GameShop } from "@types";
|
||||
|
||||
const updateGameCustomAssets = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
shop: GameShop,
|
||||
objectId: string,
|
||||
title: string,
|
||||
customIconUrl?: string | null,
|
||||
customLogoImageUrl?: string | null,
|
||||
customHeroImageUrl?: string | null
|
||||
) => {
|
||||
const gameKey = levelKeys.game(shop, objectId);
|
||||
|
||||
const existingGame = await gamesSublevel.get(gameKey);
|
||||
if (!existingGame) {
|
||||
throw new Error("Game not found");
|
||||
}
|
||||
|
||||
const updatedGame = {
|
||||
...existingGame,
|
||||
title,
|
||||
...(customIconUrl !== undefined && { customIconUrl }),
|
||||
...(customLogoImageUrl !== undefined && { customLogoImageUrl }),
|
||||
...(customHeroImageUrl !== undefined && { customHeroImageUrl }),
|
||||
};
|
||||
|
||||
await gamesSublevel.put(gameKey, updatedGame);
|
||||
|
||||
// Also update the shop assets for non-custom games
|
||||
const existingAssets = await gamesShopAssetsSublevel.get(gameKey);
|
||||
if (existingAssets) {
|
||||
const updatedAssets = {
|
||||
...existingAssets,
|
||||
title, // Update the title in shop assets as well
|
||||
};
|
||||
|
||||
await gamesShopAssetsSublevel.put(gameKey, updatedAssets);
|
||||
}
|
||||
|
||||
return updatedGame;
|
||||
};
|
||||
|
||||
registerEvent("updateGameCustomAssets", updateGameCustomAssets);
|
||||
18
src/main/events/misc/delete-temp-file.ts
Normal file
18
src/main/events/misc/delete-temp-file.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import fs from "node:fs";
|
||||
import { registerEvent } from "../register-event";
|
||||
|
||||
const deleteTempFile = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
filePath: string
|
||||
): Promise<void> => {
|
||||
try {
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently fail - temp files will be cleaned up by OS eventually
|
||||
console.warn(`Failed to delete temp file: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
registerEvent("deleteTempFile", deleteTempFile);
|
||||
27
src/main/events/misc/save-temp-file.ts
Normal file
27
src/main/events/misc/save-temp-file.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { app } from "electron";
|
||||
import { registerEvent } from "../register-event";
|
||||
|
||||
const saveTempFile = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
fileName: string,
|
||||
fileData: Uint8Array
|
||||
): Promise<string> => {
|
||||
try {
|
||||
const tempDir = app.getPath("temp");
|
||||
const tempFilePath = path.join(
|
||||
tempDir,
|
||||
`hydra-temp-${Date.now()}-${fileName}`
|
||||
);
|
||||
|
||||
// Write the file data to temp directory
|
||||
fs.writeFileSync(tempFilePath, fileData);
|
||||
|
||||
return tempFilePath;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to save temp file: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
registerEvent("saveTempFile", saveTempFile);
|
||||
@@ -53,6 +53,8 @@ const startGameDownload = async (
|
||||
await gamesSublevel.put(gameKey, {
|
||||
title,
|
||||
iconUrl: gameAssets?.iconUrl ?? null,
|
||||
libraryHeroImageUrl: gameAssets?.libraryHeroImageUrl ?? null,
|
||||
logoImageUrl: gameAssets?.logoImageUrl ?? null,
|
||||
objectId,
|
||||
shop,
|
||||
remoteId: null,
|
||||
|
||||
@@ -64,6 +64,71 @@ app.whenReady().then(async () => {
|
||||
return net.fetch(url.pathToFileURL(decodeURI(filePath)).toString());
|
||||
});
|
||||
|
||||
protocol.handle("gradient", (request) => {
|
||||
const gradientCss = decodeURIComponent(
|
||||
request.url.slice("gradient:".length)
|
||||
);
|
||||
|
||||
// Parse gradient CSS safely without regex to prevent ReDoS
|
||||
let direction = "45deg";
|
||||
let color1 = "#4a90e2";
|
||||
let color2 = "#7b68ee";
|
||||
|
||||
// Simple string parsing approach - more secure than regex
|
||||
if (
|
||||
gradientCss.startsWith("linear-gradient(") &&
|
||||
gradientCss.endsWith(")")
|
||||
) {
|
||||
const content = gradientCss.slice(16, -1); // Remove "linear-gradient(" and ")"
|
||||
const parts = content.split(",").map((part) => part.trim());
|
||||
|
||||
if (parts.length >= 3) {
|
||||
direction = parts[0];
|
||||
color1 = parts[1];
|
||||
color2 = parts[2];
|
||||
}
|
||||
}
|
||||
|
||||
let x1 = "0%",
|
||||
y1 = "0%",
|
||||
x2 = "100%",
|
||||
y2 = "100%";
|
||||
|
||||
if (direction === "to right") {
|
||||
y2 = "0%";
|
||||
} else if (direction === "to bottom") {
|
||||
x2 = "0%";
|
||||
} else if (direction === "45deg") {
|
||||
y1 = "100%";
|
||||
y2 = "0%";
|
||||
} else if (direction === "225deg") {
|
||||
x1 = "100%";
|
||||
x2 = "0%";
|
||||
} else if (direction === "315deg") {
|
||||
x1 = "100%";
|
||||
y1 = "100%";
|
||||
x2 = "0%";
|
||||
y2 = "0%";
|
||||
}
|
||||
// Note: "135deg" case removed as it uses all default values
|
||||
|
||||
const svgContent = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="300" viewBox="0 0 400 300">
|
||||
<defs>
|
||||
<linearGradient id="grad" x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}">
|
||||
<stop offset="0%" style="stop-color:${color1};stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:${color2};stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="url(#grad)" />
|
||||
</svg>
|
||||
`;
|
||||
|
||||
return new Response(svgContent, {
|
||||
headers: { "Content-Type": "image/svg+xml" },
|
||||
});
|
||||
});
|
||||
|
||||
await loadState();
|
||||
|
||||
const language = await db
|
||||
|
||||
@@ -46,6 +46,8 @@ export const mergeWithRemoteGames = async () => {
|
||||
remoteId: game.id,
|
||||
shop: game.shop,
|
||||
iconUrl: game.iconUrl,
|
||||
libraryHeroImageUrl: game.libraryHeroImageUrl,
|
||||
logoImageUrl: game.logoImageUrl,
|
||||
lastTimePlayed: game.lastTimePlayed,
|
||||
playTimeInMilliseconds: game.playTimeInMilliseconds,
|
||||
hasManuallyUpdatedPlaytime: game.hasManuallyUpdatedPlaytime,
|
||||
@@ -58,7 +60,7 @@ export const mergeWithRemoteGames = async () => {
|
||||
await gamesShopAssetsSublevel.put(gameKey, {
|
||||
shop: game.shop,
|
||||
objectId: game.objectId,
|
||||
title: game.title,
|
||||
title: localGame?.title || game.title, // Preserve local title if it exists
|
||||
coverImageUrl: game.coverImageUrl,
|
||||
libraryHeroImageUrl: game.libraryHeroImageUrl,
|
||||
libraryImageUrl: game.libraryImageUrl,
|
||||
|
||||
@@ -11,7 +11,8 @@ export const uploadGamesBatch = async () => {
|
||||
.all()
|
||||
.then((results) => {
|
||||
return results.filter(
|
||||
(game) => !game.isDeleted && game.remoteId === null
|
||||
(game) =>
|
||||
!game.isDeleted && game.remoteId === null && game.shop !== "custom"
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -128,6 +128,64 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
),
|
||||
addGameToLibrary: (shop: GameShop, objectId: string, title: string) =>
|
||||
ipcRenderer.invoke("addGameToLibrary", shop, objectId, title),
|
||||
addCustomGameToLibrary: (
|
||||
title: string,
|
||||
executablePath: string,
|
||||
iconUrl?: string,
|
||||
logoImageUrl?: string,
|
||||
libraryHeroImageUrl?: string
|
||||
) =>
|
||||
ipcRenderer.invoke(
|
||||
"addCustomGameToLibrary",
|
||||
title,
|
||||
executablePath,
|
||||
iconUrl,
|
||||
logoImageUrl,
|
||||
libraryHeroImageUrl
|
||||
),
|
||||
copyCustomGameAsset: (
|
||||
sourcePath: string,
|
||||
assetType: "icon" | "logo" | "hero"
|
||||
) => ipcRenderer.invoke("copyCustomGameAsset", sourcePath, assetType),
|
||||
saveTempFile: (fileName: string, fileData: Uint8Array) =>
|
||||
ipcRenderer.invoke("saveTempFile", fileName, fileData),
|
||||
deleteTempFile: (filePath: string) =>
|
||||
ipcRenderer.invoke("deleteTempFile", filePath),
|
||||
cleanupUnusedAssets: () => ipcRenderer.invoke("cleanupUnusedAssets"),
|
||||
updateCustomGame: (
|
||||
shop: GameShop,
|
||||
objectId: string,
|
||||
title: string,
|
||||
iconUrl?: string,
|
||||
logoImageUrl?: string,
|
||||
libraryHeroImageUrl?: string
|
||||
) =>
|
||||
ipcRenderer.invoke(
|
||||
"updateCustomGame",
|
||||
shop,
|
||||
objectId,
|
||||
title,
|
||||
iconUrl,
|
||||
logoImageUrl,
|
||||
libraryHeroImageUrl
|
||||
),
|
||||
updateGameCustomAssets: (
|
||||
shop: GameShop,
|
||||
objectId: string,
|
||||
title: string,
|
||||
customIconUrl?: string | null,
|
||||
customLogoImageUrl?: string | null,
|
||||
customHeroImageUrl?: string | null
|
||||
) =>
|
||||
ipcRenderer.invoke(
|
||||
"updateGameCustomAssets",
|
||||
shop,
|
||||
objectId,
|
||||
title,
|
||||
customIconUrl,
|
||||
customLogoImageUrl,
|
||||
customHeroImageUrl
|
||||
),
|
||||
createGameShortcut: (
|
||||
shop: GameShop,
|
||||
objectId: string,
|
||||
|
||||
4
src/renderer/src/assets/play-logo.svg
Normal file
4
src/renderer/src/assets/play-logo.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="white" class="size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.91 11.672a.375.375 0 0 1 0 .656l-5.603 3.113a.375.375 0 0 1-.557-.328V8.887c0-.286.307-.466.557-.327l5.603 3.112Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 409 B |
@@ -0,0 +1,52 @@
|
||||
@use "../../scss/globals.scss";
|
||||
|
||||
.sidebar-adding-custom-game-modal {
|
||||
&__container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(globals.$spacing-unit * 3);
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
&__image-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
&__image-preview {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: globals.$spacing-unit;
|
||||
border: 1px solid globals.$border-color;
|
||||
border-radius: 4px;
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
&__preview-image {
|
||||
max-width: 120px;
|
||||
max-height: 80px;
|
||||
width: auto;
|
||||
height: auto;
|
||||
border-radius: 4px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { FileDirectoryIcon } from "@primer/octicons-react";
|
||||
|
||||
import { Modal, TextField, Button } from "@renderer/components";
|
||||
import { useLibrary, useToast } from "@renderer/hooks";
|
||||
import {
|
||||
buildGameDetailsPath,
|
||||
generateRandomGradient,
|
||||
} from "@renderer/helpers";
|
||||
|
||||
import "./sidebar-adding-custom-game-modal.scss";
|
||||
|
||||
export interface SidebarAddingCustomGameModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function SidebarAddingCustomGameModal({
|
||||
visible,
|
||||
onClose,
|
||||
}: Readonly<SidebarAddingCustomGameModalProps>) {
|
||||
const { t } = useTranslation("sidebar");
|
||||
const { updateLibrary } = useLibrary();
|
||||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [gameName, setGameName] = useState("");
|
||||
const [executablePath, setExecutablePath] = useState("");
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
|
||||
const handleSelectExecutable = async () => {
|
||||
const { filePaths } = await window.electron.showOpenDialog({
|
||||
properties: ["openFile"],
|
||||
filters: [
|
||||
{
|
||||
name: t("custom_game_modal_executable"),
|
||||
extensions: ["exe", "msi", "app", "deb", "rpm", "dmg"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (filePaths && filePaths.length > 0) {
|
||||
const selectedPath = filePaths[0];
|
||||
setExecutablePath(selectedPath);
|
||||
|
||||
if (!gameName.trim()) {
|
||||
const fileName = selectedPath.split(/[\\/]/).pop() || "";
|
||||
const gameNameFromFile = fileName.replace(/\.[^/.]+$/, "");
|
||||
setGameName(gameNameFromFile);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleGameNameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setGameName(event.target.value);
|
||||
};
|
||||
|
||||
const handleAddGame = async () => {
|
||||
if (!gameName.trim() || !executablePath.trim()) {
|
||||
showErrorToast(t("custom_game_modal_fill_required"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsAdding(true);
|
||||
|
||||
try {
|
||||
// Generate gradient URL only for hero image
|
||||
const gameNameForSeed = gameName.trim();
|
||||
const iconUrl = ""; // Don't use gradient for icon
|
||||
const logoImageUrl = ""; // Don't use gradient for logo
|
||||
const libraryHeroImageUrl = generateRandomGradient(); // Only use gradient for hero
|
||||
|
||||
const newGame = await window.electron.addCustomGameToLibrary(
|
||||
gameNameForSeed,
|
||||
executablePath,
|
||||
iconUrl,
|
||||
logoImageUrl,
|
||||
libraryHeroImageUrl
|
||||
);
|
||||
|
||||
showSuccessToast(t("custom_game_modal_success"));
|
||||
updateLibrary();
|
||||
|
||||
const gameDetailsPath = buildGameDetailsPath({
|
||||
shop: "custom",
|
||||
objectId: newGame.objectId,
|
||||
title: newGame.title,
|
||||
});
|
||||
|
||||
navigate(gameDetailsPath);
|
||||
|
||||
setGameName("");
|
||||
setExecutablePath("");
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to add custom game:", error);
|
||||
showErrorToast(
|
||||
error instanceof Error ? error.message : t("custom_game_modal_failed")
|
||||
);
|
||||
} finally {
|
||||
setIsAdding(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (!isAdding) {
|
||||
setGameName("");
|
||||
setExecutablePath("");
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const isFormValid = gameName.trim() && executablePath.trim();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
title={t("custom_game_modal")}
|
||||
description={t("custom_game_modal_description")}
|
||||
onClose={handleClose}
|
||||
>
|
||||
<div className="sidebar-adding-custom-game-modal__container">
|
||||
<div className="sidebar-adding-custom-game-modal__form">
|
||||
<TextField
|
||||
label={t("custom_game_modal_executable_path")}
|
||||
placeholder={t("custom_game_modal_select_executable")}
|
||||
value={executablePath}
|
||||
readOnly
|
||||
theme="dark"
|
||||
rightContent={
|
||||
<Button
|
||||
type="button"
|
||||
theme="outline"
|
||||
onClick={handleSelectExecutable}
|
||||
disabled={isAdding}
|
||||
>
|
||||
<FileDirectoryIcon />
|
||||
{t("custom_game_modal_browse")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label={t("custom_game_modal_title")}
|
||||
placeholder={t("custom_game_modal_enter_title")}
|
||||
value={gameName}
|
||||
onChange={handleGameNameChange}
|
||||
theme="dark"
|
||||
disabled={isAdding}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sidebar-adding-custom-game-modal__actions">
|
||||
<Button
|
||||
type="button"
|
||||
theme="outline"
|
||||
onClick={handleClose}
|
||||
disabled={isAdding}
|
||||
>
|
||||
{t("custom_game_modal_cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
theme="primary"
|
||||
onClick={handleAddGame}
|
||||
disabled={!isFormValid || isAdding}
|
||||
>
|
||||
{isAdding
|
||||
? t("custom_game_modal_adding")
|
||||
: t("custom_game_modal_add")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
|
||||
import PlayLogo from "@renderer/assets/play-logo.svg?react";
|
||||
import { LibraryGame } from "@types";
|
||||
import cn from "classnames";
|
||||
import { useLocation } from "react-router-dom";
|
||||
@@ -16,6 +17,19 @@ export function SidebarGameItem({
|
||||
}: Readonly<SidebarGameItemProps>) {
|
||||
const location = useLocation();
|
||||
|
||||
const isCustomGame = game.shop === "custom";
|
||||
const sidebarIcon = isCustomGame
|
||||
? game.libraryImageUrl || game.iconUrl
|
||||
: game.customIconUrl || game.iconUrl;
|
||||
|
||||
// Determine fallback icon based on game type
|
||||
const getFallbackIcon = () => {
|
||||
if (isCustomGame) {
|
||||
return <PlayLogo className="sidebar__game-icon" />;
|
||||
}
|
||||
return <SteamLogo className="sidebar__game-icon" />;
|
||||
};
|
||||
|
||||
return (
|
||||
<li
|
||||
key={game.id}
|
||||
@@ -30,15 +44,15 @@ export function SidebarGameItem({
|
||||
className="sidebar__menu-item-button"
|
||||
onClick={(event) => handleSidebarGameClick(event, game)}
|
||||
>
|
||||
{game.iconUrl ? (
|
||||
{sidebarIcon ? (
|
||||
<img
|
||||
className="sidebar__game-icon"
|
||||
src={game.iconUrl}
|
||||
src={sidebarIcon}
|
||||
alt={game.title}
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<SteamLogo className="sidebar__game-icon" />
|
||||
getFallbackIcon()
|
||||
)}
|
||||
|
||||
<span className="sidebar__menu-item-button-label">
|
||||
|
||||
@@ -172,4 +172,24 @@
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
&__add-button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: globals.$muted-color;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
|
||||
&:hover {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
&:active {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
svg {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { Tooltip } from "react-tooltip";
|
||||
|
||||
import type { LibraryGame } from "@types";
|
||||
|
||||
@@ -21,8 +22,13 @@ import { buildGameDetailsPath } from "@renderer/helpers";
|
||||
import { SidebarProfile } from "./sidebar-profile";
|
||||
import { sortBy } from "lodash-es";
|
||||
import cn from "classnames";
|
||||
import { CommentDiscussionIcon, PlayIcon } from "@primer/octicons-react";
|
||||
import {
|
||||
CommentDiscussionIcon,
|
||||
PlayIcon,
|
||||
PlusIcon,
|
||||
} from "@primer/octicons-react";
|
||||
import { SidebarGameItem } from "./sidebar-game-item";
|
||||
import { SidebarAddingCustomGameModal } from "./sidebar-adding-custom-game-modal";
|
||||
import { setFriendRequestCount } from "@renderer/features/user-details-slice";
|
||||
import { useDispatch } from "react-redux";
|
||||
|
||||
@@ -63,11 +69,20 @@ export function Sidebar() {
|
||||
const { showWarningToast } = useToast();
|
||||
|
||||
const [showPlayableOnly, setShowPlayableOnly] = useState(false);
|
||||
const [showAddGameModal, setShowAddGameModal] = useState(false);
|
||||
|
||||
const handlePlayButtonClick = () => {
|
||||
setShowPlayableOnly(!showPlayableOnly);
|
||||
};
|
||||
|
||||
const handleAddGameButtonClick = () => {
|
||||
setShowAddGameModal(true);
|
||||
};
|
||||
|
||||
const handleCloseAddGameModal = () => {
|
||||
setShowAddGameModal(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
updateLibrary();
|
||||
}, [lastPacket?.gameId, updateLibrary]);
|
||||
@@ -254,15 +269,32 @@ export function Sidebar() {
|
||||
<small className="sidebar__section-title">
|
||||
{t("my_library")}
|
||||
</small>
|
||||
<button
|
||||
type="button"
|
||||
className={cn("sidebar__play-button", {
|
||||
"sidebar__play-button--active": showPlayableOnly,
|
||||
})}
|
||||
onClick={handlePlayButtonClick}
|
||||
<div
|
||||
style={{ display: "flex", gap: "8px", alignItems: "center" }}
|
||||
>
|
||||
<PlayIcon size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="sidebar__add-button"
|
||||
onClick={handleAddGameButtonClick}
|
||||
data-tooltip-id="add-custom-game-tooltip"
|
||||
data-tooltip-content={t("add_custom_game_tooltip")}
|
||||
data-tooltip-place="top"
|
||||
>
|
||||
<PlusIcon size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn("sidebar__play-button", {
|
||||
"sidebar__play-button--active": showPlayableOnly,
|
||||
})}
|
||||
onClick={handlePlayButtonClick}
|
||||
data-tooltip-id="show-playable-only-tooltip"
|
||||
data-tooltip-content={t("show_playable_only_tooltip")}
|
||||
data-tooltip-place="top"
|
||||
>
|
||||
<PlayIcon size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TextField
|
||||
@@ -307,6 +339,14 @@ export function Sidebar() {
|
||||
className="sidebar__handle"
|
||||
onMouseDown={handleMouseDown}
|
||||
/>
|
||||
|
||||
<SidebarAddingCustomGameModal
|
||||
visible={showAddGameModal}
|
||||
onClose={handleCloseAddGameModal}
|
||||
/>
|
||||
|
||||
<Tooltip id="add-custom-game-tooltip" />
|
||||
<Tooltip id="show-playable-only-tooltip" />
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -198,6 +198,12 @@ export function GameDetailsContextProvider({
|
||||
dispatch(setHeaderTitle(gameTitle));
|
||||
}, [objectId, gameTitle, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (game?.title) {
|
||||
dispatch(setHeaderTitle(game.title));
|
||||
}
|
||||
}, [game?.title, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.electron.onGamesRunning((gamesIds) => {
|
||||
const updatedIsGameRunning =
|
||||
|
||||
33
src/renderer/src/declaration.d.ts
vendored
33
src/renderer/src/declaration.d.ts
vendored
@@ -112,6 +112,37 @@ declare global {
|
||||
objectId: string,
|
||||
title: string
|
||||
) => Promise<void>;
|
||||
addCustomGameToLibrary: (
|
||||
title: string,
|
||||
executablePath: string,
|
||||
iconUrl?: string,
|
||||
logoImageUrl?: string,
|
||||
libraryHeroImageUrl?: string
|
||||
) => Promise<Game>;
|
||||
updateCustomGame: (
|
||||
shop: GameShop,
|
||||
objectId: string,
|
||||
title: string,
|
||||
iconUrl?: string,
|
||||
logoImageUrl?: string,
|
||||
libraryHeroImageUrl?: string
|
||||
) => Promise<Game>;
|
||||
copyCustomGameAsset: (
|
||||
sourcePath: string,
|
||||
assetType: "icon" | "logo" | "hero"
|
||||
) => Promise<string>;
|
||||
cleanupUnusedAssets: () => Promise<{
|
||||
deletedCount: number;
|
||||
errors: string[];
|
||||
}>;
|
||||
updateGameCustomAssets: (
|
||||
shop: GameShop,
|
||||
objectId: string,
|
||||
title: string,
|
||||
customIconUrl?: string | null,
|
||||
customLogoImageUrl?: string | null,
|
||||
customHeroImageUrl?: string | null
|
||||
) => Promise<Game>;
|
||||
createGameShortcut: (
|
||||
shop: GameShop,
|
||||
objectId: string,
|
||||
@@ -273,6 +304,8 @@ declare global {
|
||||
onCommonRedistProgress: (
|
||||
cb: (value: { log: string; complete: boolean }) => void
|
||||
) => () => Electron.IpcRenderer;
|
||||
saveTempFile: (fileName: string, fileData: Uint8Array) => Promise<string>;
|
||||
deleteTempFile: (filePath: string) => Promise<void>;
|
||||
platform: NodeJS.Platform;
|
||||
|
||||
/* Auto update */
|
||||
|
||||
@@ -84,3 +84,23 @@ export const injectCustomCss = (
|
||||
export const removeCustomCss = (target: HTMLElement = document.head) => {
|
||||
target.querySelector("#custom-css")?.remove();
|
||||
};
|
||||
|
||||
export const generateRandomGradient = (): string => {
|
||||
// Use a single consistent gradient with softer colors for custom games as placeholder
|
||||
const color1 = "#2c3e50"; // Dark blue-gray
|
||||
const color2 = "#34495e"; // Darker slate
|
||||
|
||||
// Create SVG data URL that works in img tags
|
||||
const svgContent = `<svg xmlns="http://www.w3.org/2000/svg" width="400" height="300" viewBox="0 0 400 300">
|
||||
<defs>
|
||||
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:${color1};stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:${color2};stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="url(#grad)" />
|
||||
</svg>`;
|
||||
|
||||
// Return as data URL that works in img tags
|
||||
return `data:image/svg+xml;base64,${btoa(svgContent)}`;
|
||||
};
|
||||
|
||||
@@ -28,12 +28,12 @@ export function DeleteGameModal({
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="delete-game-modal__actions">
|
||||
<Button onClick={handleDeleteGame} theme="outline">
|
||||
{t("delete")}
|
||||
<Button onClick={onClose} theme="outline">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
|
||||
<Button onClick={onClose} theme="primary">
|
||||
{t("cancel")}
|
||||
<Button onClick={handleDeleteGame} theme="primary">
|
||||
{t("delete")}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
@use "../../../scss/globals.scss";
|
||||
|
||||
.description-header {
|
||||
width: 100%;
|
||||
padding: calc(globals.$spacing-unit * 2);
|
||||
width: calc(100% - calc(globals.$spacing-unit * 2));
|
||||
margin: calc(globals.$spacing-unit * 1) auto;
|
||||
padding: calc(globals.$spacing-unit * 1.5);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background-color: globals.$background-color;
|
||||
height: 72px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.03);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
|
||||
&__info {
|
||||
display: flex;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
.gallery-slider {
|
||||
&__container {
|
||||
padding: calc(globals.$spacing-unit * 3) calc(globals.$spacing-unit * 2);
|
||||
padding: calc(globals.$spacing-unit * 1.5) calc(globals.$spacing-unit * 1);
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { PencilIcon } from "@primer/octicons-react";
|
||||
|
||||
import { HeroPanel } from "./hero";
|
||||
import { DescriptionHeader } from "./description-header/description-header";
|
||||
import { GallerySlider } from "./gallery-slider/gallery-slider";
|
||||
import { Sidebar } from "./sidebar/sidebar";
|
||||
import { EditGameModal } from "./modals";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cloudSyncContext, gameDetailsContext } from "@renderer/context";
|
||||
import { AuthPage } from "@shared";
|
||||
|
||||
import cloudIconAnimated from "@renderer/assets/icons/cloud-animated.gif";
|
||||
import { useUserDetails } from "@renderer/hooks";
|
||||
import { useUserDetails, useLibrary } from "@renderer/hooks";
|
||||
import { useSubscription } from "@renderer/hooks/use-subscription";
|
||||
import "./game-details.scss";
|
||||
|
||||
@@ -19,12 +21,13 @@ export function GameDetailsContent() {
|
||||
|
||||
const { t } = useTranslation("game_details");
|
||||
|
||||
const { objectId, shopDetails, game, hasNSFWContentBlocked } =
|
||||
const { objectId, shopDetails, game, hasNSFWContentBlocked, updateGame } =
|
||||
useContext(gameDetailsContext);
|
||||
|
||||
const { showHydraCloudModal } = useSubscription();
|
||||
|
||||
const { userDetails, hasActiveSubscription } = useUserDetails();
|
||||
const { updateLibrary } = useLibrary();
|
||||
|
||||
const { setShowCloudSyncModal, getGameArtifacts } =
|
||||
useContext(cloudSyncContext);
|
||||
@@ -45,10 +48,15 @@ export function GameDetailsContent() {
|
||||
return document.body.outerHTML;
|
||||
}
|
||||
|
||||
if (game?.shop === "custom") {
|
||||
return "";
|
||||
}
|
||||
|
||||
return t("no_shop_details");
|
||||
}, [shopDetails, t]);
|
||||
}, [shopDetails, t, game?.shop]);
|
||||
|
||||
const [backdropOpacity, setBackdropOpacity] = useState(1);
|
||||
const [showEditGameModal, setShowEditGameModal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setBackdropOpacity(1);
|
||||
@@ -68,10 +76,72 @@ export function GameDetailsContent() {
|
||||
setShowCloudSyncModal(true);
|
||||
};
|
||||
|
||||
const handleEditGameClick = () => {
|
||||
setShowEditGameModal(true);
|
||||
};
|
||||
|
||||
const handleGameUpdated = (_updatedGame: any) => {
|
||||
updateGame();
|
||||
updateLibrary();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getGameArtifacts();
|
||||
}, [getGameArtifacts]);
|
||||
|
||||
const isCustomGame = game?.shop === "custom";
|
||||
|
||||
// Helper function to get image with custom asset priority
|
||||
const getImageWithCustomPriority = (
|
||||
customUrl: string | null | undefined,
|
||||
originalUrl: string | null | undefined,
|
||||
fallbackUrl?: string | null | undefined
|
||||
) => {
|
||||
return customUrl || originalUrl || fallbackUrl || "";
|
||||
};
|
||||
|
||||
const heroImage = isCustomGame
|
||||
? game?.libraryHeroImageUrl || game?.iconUrl || ""
|
||||
: getImageWithCustomPriority(
|
||||
game?.customHeroImageUrl,
|
||||
shopDetails?.assets?.libraryHeroImageUrl
|
||||
);
|
||||
|
||||
const logoImage = isCustomGame
|
||||
? game?.logoImageUrl || ""
|
||||
: getImageWithCustomPriority(
|
||||
game?.customLogoImageUrl,
|
||||
shopDetails?.assets?.logoImageUrl
|
||||
);
|
||||
|
||||
const renderGameLogo = () => {
|
||||
if (isCustomGame) {
|
||||
// For custom games, show logo image if available, otherwise show game title as text
|
||||
if (logoImage) {
|
||||
return (
|
||||
<img
|
||||
src={logoImage}
|
||||
className="game-details__game-logo"
|
||||
alt={game?.title}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div className="game-details__game-logo-text">{game?.title}</div>
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// For non-custom games, show logo image if available
|
||||
return logoImage ? (
|
||||
<img
|
||||
src={logoImage}
|
||||
className="game-details__game-logo"
|
||||
alt={game?.title}
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`game-details__wrapper ${hasNSFWContentBlocked ? "game-details__wrapper--blurred" : ""}`}
|
||||
@@ -79,7 +149,7 @@ export function GameDetailsContent() {
|
||||
<section className="game-details__container">
|
||||
<div ref={heroRef} className="game-details__hero">
|
||||
<img
|
||||
src={shopDetails?.assets?.libraryHeroImageUrl ?? ""}
|
||||
src={heroImage}
|
||||
className="game-details__hero-image"
|
||||
alt={game?.title}
|
||||
/>
|
||||
@@ -95,26 +165,35 @@ export function GameDetailsContent() {
|
||||
style={{ opacity: backdropOpacity }}
|
||||
>
|
||||
<div className="game-details__hero-content">
|
||||
<img
|
||||
src={shopDetails?.assets?.logoImageUrl ?? ""}
|
||||
className="game-details__game-logo"
|
||||
alt={game?.title}
|
||||
/>
|
||||
{renderGameLogo()}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="game-details__cloud-sync-button"
|
||||
onClick={handleCloudSaveButtonClick}
|
||||
>
|
||||
<div className="game-details__cloud-icon-container">
|
||||
<img
|
||||
src={cloudIconAnimated}
|
||||
alt="Cloud icon"
|
||||
className="game-details__cloud-icon"
|
||||
/>
|
||||
</div>
|
||||
{t("cloud_save")}
|
||||
</button>
|
||||
<div className="game-details__hero-buttons game-details__hero-buttons--right">
|
||||
<button
|
||||
type="button"
|
||||
className="game-details__edit-custom-game-button"
|
||||
onClick={handleEditGameClick}
|
||||
title={t("edit_game_modal_button")}
|
||||
>
|
||||
<PencilIcon size={16} />
|
||||
</button>
|
||||
|
||||
{game?.shop !== "custom" && (
|
||||
<button
|
||||
type="button"
|
||||
className="game-details__cloud-sync-button"
|
||||
onClick={handleCloudSaveButtonClick}
|
||||
>
|
||||
<div className="game-details__cloud-icon-container">
|
||||
<img
|
||||
src={cloudIconAnimated}
|
||||
alt="Cloud icon"
|
||||
className="game-details__cloud-icon"
|
||||
/>
|
||||
</div>
|
||||
{t("cloud_save")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -134,9 +213,17 @@ export function GameDetailsContent() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Sidebar />
|
||||
{game?.shop !== "custom" && <Sidebar />}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<EditGameModal
|
||||
visible={showEditGameModal}
|
||||
onClose={() => setShowEditGameModal(false)}
|
||||
game={game}
|
||||
shopDetails={shopDetails}
|
||||
onGameUpdated={handleGameUpdated}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,12 +43,53 @@ $hero-height: 300px;
|
||||
}
|
||||
|
||||
&__hero-content {
|
||||
padding: calc(globals.$spacing-unit * 2);
|
||||
padding: calc(globals.$spacing-unit * 1.5);
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
padding: calc(globals.$spacing-unit * 2);
|
||||
}
|
||||
}
|
||||
|
||||
&__hero-buttons {
|
||||
display: flex;
|
||||
gap: globals.$spacing-unit;
|
||||
align-items: center;
|
||||
|
||||
&--right {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
&__edit-custom-game-button {
|
||||
padding: calc(globals.$spacing-unit * 1.5);
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(20px);
|
||||
border-radius: 8px;
|
||||
transition: all ease 0.2s;
|
||||
cursor: pointer;
|
||||
min-height: 40px;
|
||||
min-width: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: globals.$muted-color;
|
||||
border: solid 1px globals.$border-color;
|
||||
box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.8);
|
||||
animation: slide-in 0.3s cubic-bezier(0.33, 1, 0.68, 1);
|
||||
|
||||
&:active {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
color: globals.$body-color;
|
||||
}
|
||||
}
|
||||
|
||||
&__hero-logo-backdrop {
|
||||
@@ -79,8 +120,40 @@ $hero-height: 300px;
|
||||
}
|
||||
|
||||
&__game-logo {
|
||||
width: 300px;
|
||||
width: 200px;
|
||||
align-self: flex-end;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
width: 250px;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
width: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
&__game-logo-text {
|
||||
width: 200px;
|
||||
align-self: flex-end;
|
||||
font-size: 1.8rem;
|
||||
font-weight: bold;
|
||||
color: #ffffff;
|
||||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.8);
|
||||
text-align: left;
|
||||
line-height: 1.2;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
hyphens: auto;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
width: 250px;
|
||||
font-size: 2.2rem;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
width: 300px;
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__hero-image-skeleton {
|
||||
@@ -122,11 +195,19 @@ $hero-height: 300px;
|
||||
user-select: text;
|
||||
line-height: 22px;
|
||||
font-size: globals.$body-font-size;
|
||||
padding: calc(globals.$spacing-unit * 3) calc(globals.$spacing-unit * 2);
|
||||
padding: calc(globals.$spacing-unit * 2) calc(globals.$spacing-unit * 1.5);
|
||||
width: 100%;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
padding: calc(globals.$spacing-unit * 2.5) calc(globals.$spacing-unit * 2);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
padding: calc(globals.$spacing-unit * 3) calc(globals.$spacing-unit * 2);
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
width: 60%;
|
||||
}
|
||||
@@ -155,11 +236,19 @@ $hero-height: 300px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: globals.$spacing-unit;
|
||||
padding: calc(globals.$spacing-unit * 3) calc(globals.$spacing-unit * 2);
|
||||
padding: calc(globals.$spacing-unit * 2) calc(globals.$spacing-unit * 1.5);
|
||||
width: 100%;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
padding: calc(globals.$spacing-unit * 2.5) calc(globals.$spacing-unit * 2);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
padding: calc(globals.$spacing-unit * 3) calc(globals.$spacing-unit * 2);
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
width: 60%;
|
||||
line-height: 22px;
|
||||
|
||||
@@ -178,6 +178,7 @@ export default function GameDetails() {
|
||||
onClose={() => {
|
||||
setShowGameOptionsModal(false);
|
||||
}}
|
||||
onNavigateHome={() => navigate("/")}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -229,7 +229,7 @@ export function HeroPanelActions() {
|
||||
{game.favorite ? <HeartFillIcon /> : <HeartIcon />}
|
||||
</Button>
|
||||
|
||||
{userDetails && (
|
||||
{userDetails && game.shop !== "custom" && (
|
||||
<Button
|
||||
onClick={toggleGamePinned}
|
||||
theme="outline"
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
&__manual-warning {
|
||||
|
||||
@@ -90,7 +90,7 @@ export function HeroPanelPlaytime() {
|
||||
<>
|
||||
<p
|
||||
className="hero-panel-playtime__play-time"
|
||||
data-tooltip-place="top"
|
||||
data-tooltip-place="right"
|
||||
data-tooltip-content={
|
||||
game.hasManuallyUpdatedPlaytime
|
||||
? t("manual_playtime_tooltip")
|
||||
|
||||
@@ -149,17 +149,17 @@ export function ChangeGamePlaytimeModal({
|
||||
</div>
|
||||
|
||||
<div className="change-game-playtime-modal__actions">
|
||||
<Button onClick={onClose} theme="outline">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={handleChangePlaytime}
|
||||
theme="outline"
|
||||
theme="primary"
|
||||
disabled={!isValid || isSubmitting}
|
||||
>
|
||||
{t("update_playtime")}
|
||||
</Button>
|
||||
|
||||
<Button onClick={onClose} theme="primary">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
181
src/renderer/src/pages/game-details/modals/edit-game-modal.scss
Normal file
181
src/renderer/src/pages/game-details/modals/edit-game-modal.scss
Normal file
@@ -0,0 +1,181 @@
|
||||
.edit-game-modal__container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.edit-game-modal__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.edit-game-modal__asset-selector {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.edit-game-modal__asset-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
|
||||
button {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.edit-game-modal__asset-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.edit-game-modal__image-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.edit-game-modal__resolution-info {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
margin-top: -4px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.edit-game-modal__image-preview {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background-color: var(--color-background-secondary);
|
||||
background-image: linear-gradient(
|
||||
45deg,
|
||||
rgba(255, 255, 255, 0.1) 25%,
|
||||
transparent 25%
|
||||
),
|
||||
linear-gradient(-45deg, rgba(255, 255, 255, 0.1) 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, rgba(255, 255, 255, 0.1) 75%),
|
||||
linear-gradient(-45deg, transparent 75%, rgba(255, 255, 255, 0.1) 75%);
|
||||
background-size: 16px 16px;
|
||||
background-position:
|
||||
0 0,
|
||||
0 8px,
|
||||
8px -8px,
|
||||
-8px 0px;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
background-color 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
position: relative;
|
||||
|
||||
/* Reset button styles when used as button element */
|
||||
&[type="button"] {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
text-align: inherit;
|
||||
text-decoration: none;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
|
||||
&:focus {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.edit-game-modal__drop-zone {
|
||||
min-height: 120px;
|
||||
cursor: pointer;
|
||||
border-style: dashed !important;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-primary);
|
||||
background-color: rgba(var(--color-primary-rgb), 0.05);
|
||||
}
|
||||
|
||||
&--active {
|
||||
border-color: var(--color-primary) !important;
|
||||
background-color: rgba(var(--color-primary-rgb), 0.1) !important;
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 4px 12px rgba(var(--color-primary-rgb), 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.edit-game-modal__drop-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(var(--color-primary-rgb), 0.9);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
backdrop-filter: blur(2px);
|
||||
animation: fadeIn 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.edit-game-modal__drop-zone-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 14px;
|
||||
|
||||
svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.edit-game-modal__icon-preview {
|
||||
max-width: 200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.edit-game-modal__preview-image {
|
||||
max-width: 100%;
|
||||
max-height: 120px;
|
||||
object-fit: contain;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.edit-game-modal__actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.edit-game-modal__actions button {
|
||||
min-width: 100px;
|
||||
}
|
||||
552
src/renderer/src/pages/game-details/modals/edit-game-modal.tsx
Normal file
552
src/renderer/src/pages/game-details/modals/edit-game-modal.tsx
Normal file
@@ -0,0 +1,552 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ImageIcon, XIcon } from "@primer/octicons-react";
|
||||
|
||||
import { Modal, TextField, Button } from "@renderer/components";
|
||||
import { useToast } from "@renderer/hooks";
|
||||
import type { LibraryGame, Game, ShopDetailsWithAssets } from "@types";
|
||||
|
||||
import "./edit-game-modal.scss";
|
||||
|
||||
export interface EditGameModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
game: LibraryGame | Game | null;
|
||||
shopDetails?: ShopDetailsWithAssets | null;
|
||||
onGameUpdated: (updatedGame: LibraryGame | Game) => void;
|
||||
}
|
||||
|
||||
type AssetType = "icon" | "logo" | "hero";
|
||||
|
||||
export function EditGameModal({
|
||||
visible,
|
||||
onClose,
|
||||
game,
|
||||
shopDetails,
|
||||
onGameUpdated,
|
||||
}: Readonly<EditGameModalProps>) {
|
||||
const { t } = useTranslation("sidebar");
|
||||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
|
||||
const [gameName, setGameName] = useState("");
|
||||
const [iconPath, setIconPath] = useState("");
|
||||
const [logoPath, setLogoPath] = useState("");
|
||||
const [heroPath, setHeroPath] = useState("");
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [selectedAssetType, setSelectedAssetType] = useState<AssetType>("icon");
|
||||
|
||||
const [defaultIconUrl, setDefaultIconUrl] = useState<string | null>(null);
|
||||
const [defaultLogoUrl, setDefaultLogoUrl] = useState<string | null>(null);
|
||||
const [defaultHeroUrl, setDefaultHeroUrl] = useState<string | null>(null);
|
||||
|
||||
const isCustomGame = (game: LibraryGame | Game): boolean => {
|
||||
return game.shop === "custom";
|
||||
};
|
||||
|
||||
const extractLocalPath = (url: string | null | undefined): string => {
|
||||
return url?.startsWith("local:") ? url.replace("local:", "") : "";
|
||||
};
|
||||
|
||||
const setCustomGameAssets = useCallback((game: LibraryGame | Game) => {
|
||||
setIconPath(extractLocalPath(game.iconUrl));
|
||||
setLogoPath(extractLocalPath(game.logoImageUrl));
|
||||
setHeroPath(extractLocalPath(game.libraryHeroImageUrl));
|
||||
}, []);
|
||||
|
||||
const setNonCustomGameAssets = useCallback(
|
||||
(game: LibraryGame) => {
|
||||
setIconPath(extractLocalPath(game.customIconUrl));
|
||||
setLogoPath(extractLocalPath(game.customLogoImageUrl));
|
||||
setHeroPath(extractLocalPath(game.customHeroImageUrl));
|
||||
|
||||
setDefaultIconUrl(shopDetails?.assets?.iconUrl || game.iconUrl || null);
|
||||
setDefaultLogoUrl(
|
||||
shopDetails?.assets?.logoImageUrl || game.logoImageUrl || null
|
||||
);
|
||||
setDefaultHeroUrl(
|
||||
shopDetails?.assets?.libraryHeroImageUrl ||
|
||||
game.libraryHeroImageUrl ||
|
||||
null
|
||||
);
|
||||
},
|
||||
[shopDetails]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (game && visible) {
|
||||
setGameName(game.title || "");
|
||||
|
||||
if (isCustomGame(game)) {
|
||||
setCustomGameAssets(game);
|
||||
} else {
|
||||
setNonCustomGameAssets(game as LibraryGame);
|
||||
}
|
||||
}
|
||||
}, [game, visible, shopDetails, setCustomGameAssets, setNonCustomGameAssets]);
|
||||
|
||||
const handleGameNameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setGameName(event.target.value);
|
||||
};
|
||||
|
||||
const handleAssetTypeChange = (assetType: AssetType) => {
|
||||
setSelectedAssetType(assetType);
|
||||
};
|
||||
|
||||
const getAssetPath = (assetType: AssetType): string => {
|
||||
switch (assetType) {
|
||||
case "icon":
|
||||
return iconPath;
|
||||
case "logo":
|
||||
return logoPath;
|
||||
case "hero":
|
||||
return heroPath;
|
||||
}
|
||||
};
|
||||
|
||||
const setAssetPath = (assetType: AssetType, path: string): void => {
|
||||
switch (assetType) {
|
||||
case "icon":
|
||||
setIconPath(path);
|
||||
break;
|
||||
case "logo":
|
||||
setLogoPath(path);
|
||||
break;
|
||||
case "hero":
|
||||
setHeroPath(path);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const getDefaultUrl = (assetType: AssetType): string | null => {
|
||||
switch (assetType) {
|
||||
case "icon":
|
||||
return defaultIconUrl;
|
||||
case "logo":
|
||||
return defaultLogoUrl;
|
||||
case "hero":
|
||||
return defaultHeroUrl;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectAsset = async (assetType: AssetType) => {
|
||||
const { filePaths } = await window.electron.showOpenDialog({
|
||||
properties: ["openFile"],
|
||||
filters: [
|
||||
{
|
||||
name: t("edit_game_modal_image_filter"),
|
||||
extensions: ["jpg", "jpeg", "png", "gif", "webp"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (filePaths && filePaths.length > 0) {
|
||||
try {
|
||||
const copiedAssetUrl = await window.electron.copyCustomGameAsset(
|
||||
filePaths[0],
|
||||
assetType
|
||||
);
|
||||
setAssetPath(assetType, copiedAssetUrl.replace("local:", ""));
|
||||
} catch (error) {
|
||||
console.error(`Failed to copy ${assetType} asset:`, error);
|
||||
setAssetPath(assetType, filePaths[0]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestoreDefault = (assetType: AssetType) => {
|
||||
setAssetPath(assetType, "");
|
||||
};
|
||||
|
||||
const [dragOverTarget, setDragOverTarget] = useState<string | null>(null);
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const handleDragEnter = (e: React.DragEvent, target: string) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragOverTarget(target);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
|
||||
setDragOverTarget(null);
|
||||
}
|
||||
};
|
||||
|
||||
const validateImageFile = (file: File): boolean => {
|
||||
const validTypes = [
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
];
|
||||
return validTypes.includes(file.type);
|
||||
};
|
||||
|
||||
const processDroppedFile = async (file: File, assetType: AssetType) => {
|
||||
setDragOverTarget(null);
|
||||
|
||||
if (!validateImageFile(file)) {
|
||||
showErrorToast("Invalid file type. Please select an image file.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let filePath: string;
|
||||
|
||||
interface ElectronFile extends File {
|
||||
path?: string;
|
||||
}
|
||||
|
||||
if ("path" in file && typeof (file as ElectronFile).path === "string") {
|
||||
filePath = (file as ElectronFile).path!;
|
||||
} else {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const uint8Array = new Uint8Array(arrayBuffer);
|
||||
|
||||
const tempFileName = `temp_${Date.now()}_${file.name}`;
|
||||
const tempPath = await window.electron.saveTempFile?.(
|
||||
tempFileName,
|
||||
uint8Array
|
||||
);
|
||||
|
||||
if (!tempPath) {
|
||||
throw new Error(
|
||||
"Unable to process file. Drag and drop may not be fully supported."
|
||||
);
|
||||
}
|
||||
|
||||
filePath = tempPath;
|
||||
}
|
||||
|
||||
const copiedAssetUrl = await window.electron.copyCustomGameAsset(
|
||||
filePath,
|
||||
assetType
|
||||
);
|
||||
|
||||
const assetPath = copiedAssetUrl.replace("local:", "");
|
||||
setAssetPath(assetType, assetPath);
|
||||
|
||||
showSuccessToast(
|
||||
`${assetType.charAt(0).toUpperCase() + assetType.slice(1)} updated successfully!`
|
||||
);
|
||||
|
||||
if (!("path" in file) && filePath) {
|
||||
try {
|
||||
await window.electron.deleteTempFile?.(filePath);
|
||||
} catch (cleanupError) {
|
||||
console.warn("Failed to clean up temporary file:", cleanupError);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to process dropped ${assetType}:`, error);
|
||||
showErrorToast(
|
||||
`Failed to process dropped ${assetType}. Please try again.`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssetDrop = async (e: React.DragEvent, assetType: AssetType) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragOverTarget(null);
|
||||
|
||||
if (isUpdating) return;
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
if (files.length > 0) {
|
||||
await processDroppedFile(files[0], assetType);
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to prepare custom game assets
|
||||
const prepareCustomGameAssets = (game: LibraryGame | Game) => {
|
||||
const iconUrl = iconPath ? `local:${iconPath}` : game.iconUrl;
|
||||
const logoImageUrl = logoPath ? `local:${logoPath}` : game.logoImageUrl;
|
||||
const libraryHeroImageUrl = heroPath
|
||||
? `local:${heroPath}`
|
||||
: game.libraryHeroImageUrl;
|
||||
|
||||
return { iconUrl, logoImageUrl, libraryHeroImageUrl };
|
||||
};
|
||||
|
||||
// Helper function to prepare non-custom game assets
|
||||
const prepareNonCustomGameAssets = () => {
|
||||
return {
|
||||
customIconUrl: iconPath ? `local:${iconPath}` : null,
|
||||
customLogoImageUrl: logoPath ? `local:${logoPath}` : null,
|
||||
customHeroImageUrl: heroPath ? `local:${heroPath}` : null,
|
||||
};
|
||||
};
|
||||
|
||||
// Helper function to update custom game
|
||||
const updateCustomGame = async (game: LibraryGame | Game) => {
|
||||
const { iconUrl, logoImageUrl, libraryHeroImageUrl } =
|
||||
prepareCustomGameAssets(game);
|
||||
|
||||
return window.electron.updateCustomGame(
|
||||
game.shop,
|
||||
game.objectId,
|
||||
gameName.trim(),
|
||||
iconUrl || undefined,
|
||||
logoImageUrl || undefined,
|
||||
libraryHeroImageUrl || undefined
|
||||
);
|
||||
};
|
||||
|
||||
// Helper function to update non-custom game
|
||||
const updateNonCustomGame = async (game: LibraryGame) => {
|
||||
const { customIconUrl, customLogoImageUrl, customHeroImageUrl } =
|
||||
prepareNonCustomGameAssets();
|
||||
|
||||
return window.electron.updateGameCustomAssets(
|
||||
game.shop,
|
||||
game.objectId,
|
||||
gameName.trim(),
|
||||
customIconUrl,
|
||||
customLogoImageUrl,
|
||||
customHeroImageUrl
|
||||
);
|
||||
};
|
||||
|
||||
const handleUpdateGame = async () => {
|
||||
if (!game || !gameName.trim()) {
|
||||
showErrorToast(t("edit_game_modal_fill_required"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUpdating(true);
|
||||
|
||||
try {
|
||||
const updatedGame =
|
||||
game && isCustomGame(game)
|
||||
? await updateCustomGame(game)
|
||||
: await updateNonCustomGame(game as LibraryGame);
|
||||
|
||||
showSuccessToast(t("edit_game_modal_success"));
|
||||
onGameUpdated(updatedGame);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to update game:", error);
|
||||
showErrorToast(
|
||||
error instanceof Error ? error.message : t("edit_game_modal_failed")
|
||||
);
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to reset form to initial state
|
||||
const resetFormToInitialState = (game: LibraryGame | Game) => {
|
||||
setGameName(game.title || "");
|
||||
|
||||
if (isCustomGame(game)) {
|
||||
setCustomGameAssets(game);
|
||||
// Clear default URLs for custom games
|
||||
setDefaultIconUrl(null);
|
||||
setDefaultLogoUrl(null);
|
||||
setDefaultHeroUrl(null);
|
||||
} else {
|
||||
setNonCustomGameAssets(game as LibraryGame);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (!isUpdating && game) {
|
||||
resetFormToInitialState(game);
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const isFormValid = gameName.trim();
|
||||
|
||||
const getPreviewUrl = (assetType: AssetType): string | undefined => {
|
||||
const assetPath = getAssetPath(assetType);
|
||||
const defaultUrl = getDefaultUrl(assetType);
|
||||
|
||||
if (game && !isCustomGame(game)) {
|
||||
return assetPath ? `local:${assetPath}` : defaultUrl || undefined;
|
||||
}
|
||||
return assetPath ? `local:${assetPath}` : undefined;
|
||||
};
|
||||
|
||||
const renderImageSection = (assetType: AssetType) => {
|
||||
const assetPath = getAssetPath(assetType);
|
||||
const defaultUrl = getDefaultUrl(assetType);
|
||||
const hasImage = assetPath || (game && !isCustomGame(game) && defaultUrl);
|
||||
const isDragOver = dragOverTarget === assetType;
|
||||
|
||||
const getTranslationKey = (suffix: string) =>
|
||||
`edit_game_modal_${assetType}${suffix}`;
|
||||
const getResolutionKey = () => `edit_game_modal_${assetType}_resolution`;
|
||||
|
||||
return (
|
||||
<div className="edit-game-modal__image-section">
|
||||
<TextField
|
||||
placeholder={t(`edit_game_modal_select_${assetType}`)}
|
||||
value={assetPath}
|
||||
readOnly
|
||||
theme="dark"
|
||||
rightContent={
|
||||
<div style={{ display: "flex", gap: "8px" }}>
|
||||
<Button
|
||||
type="button"
|
||||
theme="outline"
|
||||
onClick={() => handleSelectAsset(assetType)}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
<ImageIcon />
|
||||
{t("edit_game_modal_browse")}
|
||||
</Button>
|
||||
{game && !isCustomGame(game) && assetPath && (
|
||||
<Button
|
||||
type="button"
|
||||
theme="outline"
|
||||
onClick={() => handleRestoreDefault(assetType)}
|
||||
disabled={isUpdating}
|
||||
title={`Remove ${assetType}`}
|
||||
>
|
||||
<XIcon />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="edit-game-modal__resolution-info">
|
||||
{t(getResolutionKey())}
|
||||
</div>
|
||||
|
||||
{hasImage && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t(getTranslationKey("_drop_zone"))}
|
||||
className={`edit-game-modal__image-preview ${
|
||||
assetType === "icon" ? "edit-game-modal__icon-preview" : ""
|
||||
} ${isDragOver ? "edit-game-modal__drop-zone--active" : ""}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnter={(e) => handleDragEnter(e, assetType)}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={(e) => handleAssetDrop(e, assetType)}
|
||||
onClick={() => handleSelectAsset(assetType)}
|
||||
>
|
||||
<img
|
||||
src={getPreviewUrl(assetType)}
|
||||
alt={t(getTranslationKey("_preview"))}
|
||||
className="edit-game-modal__preview-image"
|
||||
/>
|
||||
{isDragOver && (
|
||||
<div className="edit-game-modal__drop-overlay">
|
||||
<span>Drop to replace {assetType}</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!hasImage && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t(getTranslationKey("_drop_zone_empty"))}
|
||||
className={`edit-game-modal__image-preview ${
|
||||
assetType === "icon" ? "edit-game-modal__icon-preview" : ""
|
||||
} edit-game-modal__drop-zone ${
|
||||
isDragOver ? "edit-game-modal__drop-zone--active" : ""
|
||||
}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnter={(e) => handleDragEnter(e, assetType)}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={(e) => handleAssetDrop(e, assetType)}
|
||||
onClick={() => handleSelectAsset(assetType)}
|
||||
>
|
||||
<div className="edit-game-modal__drop-zone-content">
|
||||
<ImageIcon />
|
||||
<span>Drop {assetType} image here</span>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
title={t("edit_game_modal")}
|
||||
description={t("edit_game_modal_description")}
|
||||
onClose={handleClose}
|
||||
>
|
||||
<div className="edit-game-modal__container">
|
||||
<div className="edit-game-modal__form">
|
||||
<TextField
|
||||
label={t("edit_game_modal_title")}
|
||||
placeholder={t("edit_game_modal_enter_title")}
|
||||
value={gameName}
|
||||
onChange={handleGameNameChange}
|
||||
theme="dark"
|
||||
disabled={isUpdating}
|
||||
/>
|
||||
|
||||
<div className="edit-game-modal__asset-selector">
|
||||
<div className="edit-game-modal__asset-label">
|
||||
{t("edit_game_modal_assets")}
|
||||
</div>
|
||||
<div className="edit-game-modal__asset-tabs">
|
||||
<Button
|
||||
type="button"
|
||||
theme={selectedAssetType === "icon" ? "primary" : "outline"}
|
||||
onClick={() => handleAssetTypeChange("icon")}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
{t("edit_game_modal_icon")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
theme={selectedAssetType === "logo" ? "primary" : "outline"}
|
||||
onClick={() => handleAssetTypeChange("logo")}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
{t("edit_game_modal_logo")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
theme={selectedAssetType === "hero" ? "primary" : "outline"}
|
||||
onClick={() => handleAssetTypeChange("hero")}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
{t("edit_game_modal_hero")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{renderImageSection(selectedAssetType)}
|
||||
</div>
|
||||
|
||||
<div className="edit-game-modal__actions">
|
||||
<Button
|
||||
type="button"
|
||||
theme="outline"
|
||||
onClick={handleClose}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
{t("edit_game_modal_cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
theme="primary"
|
||||
onClick={handleUpdateGame}
|
||||
disabled={!isFormValid || isUpdating}
|
||||
>
|
||||
{isUpdating
|
||||
? t("edit_game_modal_updating")
|
||||
: t("edit_game_modal_update")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -18,12 +18,14 @@ export interface GameOptionsModalProps {
|
||||
visible: boolean;
|
||||
game: LibraryGame;
|
||||
onClose: () => void;
|
||||
onNavigateHome?: () => void;
|
||||
}
|
||||
|
||||
export function GameOptionsModal({
|
||||
visible,
|
||||
game,
|
||||
onClose,
|
||||
onNavigateHome,
|
||||
}: Readonly<GameOptionsModalProps>) {
|
||||
const { t } = useTranslation("game_details");
|
||||
|
||||
@@ -90,6 +92,11 @@ export function GameOptionsModal({
|
||||
await removeGameFromLibrary(game.shop, game.objectId);
|
||||
updateGame();
|
||||
onClose();
|
||||
|
||||
// Redirect to home page if it's a custom game
|
||||
if (game.shop === "custom" && onNavigateHome) {
|
||||
onNavigateHome();
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangeExecutableLocation = async () => {
|
||||
@@ -346,14 +353,16 @@ export function GameOptionsModal({
|
||||
>
|
||||
{t("create_shortcut")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateSteamShortcut}
|
||||
theme="outline"
|
||||
disabled={creatingSteamShortcut}
|
||||
>
|
||||
<SteamLogo />
|
||||
{t("create_steam_shortcut")}
|
||||
</Button>
|
||||
{game.shop !== "custom" && (
|
||||
<Button
|
||||
onClick={handleCreateSteamShortcut}
|
||||
theme="outline"
|
||||
disabled={creatingSteamShortcut}
|
||||
>
|
||||
<SteamLogo />
|
||||
{t("create_steam_shortcut")}
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowCreateStartMenuShortcut && (
|
||||
<Button
|
||||
onClick={() => handleCreateShortcut("start_menu")}
|
||||
@@ -367,19 +376,21 @@ export function GameOptionsModal({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CheckboxField
|
||||
label={
|
||||
<div className="game-options-modal__cloud-sync-label">
|
||||
{t("enable_automatic_cloud_sync")}
|
||||
<span className="game-options-modal__cloud-sync-hydra-cloud">
|
||||
Hydra Cloud
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
checked={automaticCloudSync}
|
||||
disabled={!hasActiveSubscription || !game.executablePath}
|
||||
onChange={handleToggleAutomaticCloudSync}
|
||||
/>
|
||||
{game.shop !== "custom" && (
|
||||
<CheckboxField
|
||||
label={
|
||||
<div className="game-options-modal__cloud-sync-label">
|
||||
{t("enable_automatic_cloud_sync")}
|
||||
<span className="game-options-modal__cloud-sync-hydra-cloud">
|
||||
Hydra Cloud
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
checked={automaticCloudSync}
|
||||
disabled={!hasActiveSubscription || !game.executablePath}
|
||||
onChange={handleToggleAutomaticCloudSync}
|
||||
/>
|
||||
)}
|
||||
|
||||
{shouldShowWinePrefixConfiguration && (
|
||||
<div className="game-options-modal__wine-prefix">
|
||||
@@ -441,33 +452,35 @@ export function GameOptionsModal({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="game-options-modal__downloads">
|
||||
<div className="game-options-modal__header">
|
||||
<h2>{t("downloads_section_title")}</h2>
|
||||
<h4 className="game-options-modal__header-description">
|
||||
{t("downloads_section_description")}
|
||||
</h4>
|
||||
</div>
|
||||
{game.shop !== "custom" && (
|
||||
<div className="game-options-modal__downloads">
|
||||
<div className="game-options-modal__header">
|
||||
<h2>{t("downloads_section_title")}</h2>
|
||||
<h4 className="game-options-modal__header-description">
|
||||
{t("downloads_section_description")}
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<div className="game-options-modal__row">
|
||||
<Button
|
||||
onClick={() => setShowRepacksModal(true)}
|
||||
theme="outline"
|
||||
disabled={deleting || isGameDownloading || !repacks.length}
|
||||
>
|
||||
{t("open_download_options")}
|
||||
</Button>
|
||||
{game.download?.downloadPath && (
|
||||
<div className="game-options-modal__row">
|
||||
<Button
|
||||
onClick={handleOpenDownloadFolder}
|
||||
onClick={() => setShowRepacksModal(true)}
|
||||
theme="outline"
|
||||
disabled={deleting}
|
||||
disabled={deleting || isGameDownloading || !repacks.length}
|
||||
>
|
||||
{t("open_download_location")}
|
||||
{t("open_download_options")}
|
||||
</Button>
|
||||
)}
|
||||
{game.download?.downloadPath && (
|
||||
<Button
|
||||
onClick={handleOpenDownloadFolder}
|
||||
theme="outline"
|
||||
disabled={deleting}
|
||||
>
|
||||
{t("open_download_location")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="game-options-modal__danger-zone">
|
||||
<div className="game-options-modal__header">
|
||||
@@ -486,18 +499,20 @@ export function GameOptionsModal({
|
||||
{t("remove_from_library")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => setShowResetAchievementsModal(true)}
|
||||
theme="danger"
|
||||
disabled={
|
||||
deleting ||
|
||||
isDeletingAchievements ||
|
||||
!hasAchievements ||
|
||||
!userDetails
|
||||
}
|
||||
>
|
||||
{t("reset_achievements")}
|
||||
</Button>
|
||||
{game.shop !== "custom" && (
|
||||
<Button
|
||||
onClick={() => setShowResetAchievementsModal(true)}
|
||||
theme="danger"
|
||||
disabled={
|
||||
deleting ||
|
||||
isDeletingAchievements ||
|
||||
!hasAchievements ||
|
||||
!userDetails
|
||||
}
|
||||
>
|
||||
{t("reset_achievements")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={() => setShowChangePlaytimeModal(true)}
|
||||
@@ -506,17 +521,21 @@ export function GameOptionsModal({
|
||||
{t("update_game_playtime")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowDeleteModal(true);
|
||||
}}
|
||||
theme="danger"
|
||||
disabled={
|
||||
isGameDownloading || deleting || !game.download?.downloadPath
|
||||
}
|
||||
>
|
||||
{t("remove_files")}
|
||||
</Button>
|
||||
{game.shop !== "custom" && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowDeleteModal(true);
|
||||
}}
|
||||
theme="danger"
|
||||
disabled={
|
||||
isGameDownloading ||
|
||||
deleting ||
|
||||
!game.download?.downloadPath
|
||||
}
|
||||
>
|
||||
{t("remove_files")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./repacks-modal";
|
||||
export * from "./download-settings-modal";
|
||||
export * from "./game-options-modal";
|
||||
export * from "./edit-game-modal";
|
||||
|
||||
@@ -31,12 +31,12 @@ export function RemoveGameFromLibraryModal({
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="remove-from-library-modal__actions">
|
||||
<Button onClick={handleRemoveGame} theme="outline">
|
||||
{t("remove")}
|
||||
<Button onClick={onClose} theme="outline">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
|
||||
<Button onClick={onClose} theme="primary">
|
||||
{t("cancel")}
|
||||
<Button onClick={handleRemoveGame} theme="primary">
|
||||
{t("remove")}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -36,12 +36,12 @@ export function ResetAchievementsModal({
|
||||
})}
|
||||
>
|
||||
<div className="reset-achievements-modal__actions">
|
||||
<Button onClick={handleResetAchievements} theme="outline">
|
||||
{t("reset_achievements")}
|
||||
<Button onClick={onClose} theme="outline">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
|
||||
<Button onClick={onClose} theme="primary">
|
||||
{t("cancel")}
|
||||
<Button onClick={handleResetAchievements} theme="primary">
|
||||
{t("reset_achievements")}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -38,12 +38,12 @@ export const DeleteAllThemesModal = ({
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="delete-all-themes-modal__container">
|
||||
<Button theme="outline" onClick={handleDeleteAllThemes}>
|
||||
{t("delete_all_themes")}
|
||||
<Button theme="outline" onClick={onClose}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
|
||||
<Button theme="primary" onClick={onClose}>
|
||||
{t("cancel")}
|
||||
<Button theme="primary" onClick={handleDeleteAllThemes}>
|
||||
{t("delete_all_themes")}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -41,12 +41,12 @@ export const DeleteThemeModal = ({
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="delete-all-themes-modal__container">
|
||||
<Button theme="outline" onClick={handleDeleteTheme}>
|
||||
{t("delete_theme")}
|
||||
<Button theme="outline" onClick={onClose}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
|
||||
<Button theme="primary" onClick={onClose}>
|
||||
{t("cancel")}
|
||||
<Button theme="primary" onClick={handleDeleteTheme}>
|
||||
{t("delete_theme")}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -77,12 +77,12 @@ export const ImportThemeModal = ({
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="delete-all-themes-modal__container">
|
||||
<Button theme="outline" onClick={handleImportTheme}>
|
||||
{t("import_theme")}
|
||||
<Button theme="outline" onClick={onClose}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
|
||||
<Button theme="primary" onClick={onClose}>
|
||||
{t("cancel")}
|
||||
<Button theme="primary" onClick={handleImportTheme}>
|
||||
{t("import_theme")}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type GameShop = "steam" | "epic";
|
||||
export type GameShop = "steam" | "epic" | "custom";
|
||||
|
||||
export type ShortcutLocation = "desktop" | "start_menu";
|
||||
|
||||
|
||||
@@ -33,6 +33,11 @@ export interface User {
|
||||
export interface Game {
|
||||
title: string;
|
||||
iconUrl: string | null;
|
||||
libraryHeroImageUrl: string | null;
|
||||
logoImageUrl: string | null;
|
||||
customIconUrl?: string | null;
|
||||
customLogoImageUrl?: string | null;
|
||||
customHeroImageUrl?: string | null;
|
||||
playTimeInMilliseconds: number;
|
||||
unsyncedDeltaPlayTimeInMilliseconds?: number;
|
||||
lastTimePlayed: Date | null;
|
||||
|
||||
Reference in New Issue
Block a user