Merge pull request #1786 from hydralauncher/feat/custom-games-support
Some checks failed
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-latest) (push) Has been cancelled

Feat: Custom Games Support + Edit game info support
This commit is contained in:
Chubby Granny Chaser
2025-09-28 18:22:24 +01:00
committed by GitHub
49 changed files with 2023 additions and 155 deletions

View File

@@ -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",

View File

@@ -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": "Поиск",

View File

@@ -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);

View File

@@ -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";

View 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);

View File

@@ -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);
}
if (game) {
await createGame(game).catch(() => {});
AchievementWatcherManager.firstSyncWithRemoteIfNeeded(
game.shop,
game.objectId
);
}
};
registerEvent("addGameToLibrary", addGameToLibrary);

View File

@@ -13,16 +13,20 @@ const changeGamePlaytime = async (
const gameKey = levelKeys.game(shop, objectId);
const game = await gamesSublevel.get(gameKey);
if (!game) return;
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}`);
}
};

View 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);

View 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);

View File

@@ -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;
})
);
});

View 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);

View 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);

View 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);

View 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);

View File

@@ -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,

View File

@@ -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

View File

@@ -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,

View File

@@ -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"
);
});

View File

@@ -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,

View 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

View File

@@ -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);
}
}

View File

@@ -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>
);
}

View File

@@ -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">

View File

@@ -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;
}
}
}

View File

@@ -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,16 +269,33 @@ export function Sidebar() {
<small className="sidebar__section-title">
{t("my_library")}
</small>
<div
style={{ display: "flex", gap: "8px", alignItems: "center" }}
>
<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
ref={filterRef}
@@ -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>
);
}

View File

@@ -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 =

View File

@@ -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 */

View File

@@ -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)}`;
};

View File

@@ -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>

View File

@@ -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;

View File

@@ -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;

View File

@@ -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,12 +165,19 @@ 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()}
<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"
@@ -115,6 +192,8 @@ export function GameDetailsContent() {
</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>
);
}

View File

@@ -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;

View File

@@ -178,6 +178,7 @@ export default function GameDetails() {
onClose={() => {
setShowGameOptionsModal(false);
}}
onNavigateHome={() => navigate("/")}
/>
)}

View File

@@ -229,7 +229,7 @@ export function HeroPanelActions() {
{game.favorite ? <HeartFillIcon /> : <HeartIcon />}
</Button>
{userDetails && (
{userDetails && game.shop !== "custom" && (
<Button
onClick={toggleGamePinned}
theme="outline"

View File

@@ -17,6 +17,7 @@
display: flex;
align-items: center;
gap: 8px;
width: fit-content;
}
&__manual-warning {

View File

@@ -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")

View File

@@ -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>

View 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;
}

View 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>
);
}

View File

@@ -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,6 +353,7 @@ export function GameOptionsModal({
>
{t("create_shortcut")}
</Button>
{game.shop !== "custom" && (
<Button
onClick={handleCreateSteamShortcut}
theme="outline"
@@ -354,6 +362,7 @@ export function GameOptionsModal({
<SteamLogo />
{t("create_steam_shortcut")}
</Button>
)}
{shouldShowCreateStartMenuShortcut && (
<Button
onClick={() => handleCreateShortcut("start_menu")}
@@ -367,6 +376,7 @@ export function GameOptionsModal({
</div>
</div>
{game.shop !== "custom" && (
<CheckboxField
label={
<div className="game-options-modal__cloud-sync-label">
@@ -380,6 +390,7 @@ export function GameOptionsModal({
disabled={!hasActiveSubscription || !game.executablePath}
onChange={handleToggleAutomaticCloudSync}
/>
)}
{shouldShowWinePrefixConfiguration && (
<div className="game-options-modal__wine-prefix">
@@ -441,6 +452,7 @@ export function GameOptionsModal({
/>
</div>
{game.shop !== "custom" && (
<div className="game-options-modal__downloads">
<div className="game-options-modal__header">
<h2>{t("downloads_section_title")}</h2>
@@ -468,6 +480,7 @@ export function GameOptionsModal({
)}
</div>
</div>
)}
<div className="game-options-modal__danger-zone">
<div className="game-options-modal__header">
@@ -486,6 +499,7 @@ export function GameOptionsModal({
{t("remove_from_library")}
</Button>
{game.shop !== "custom" && (
<Button
onClick={() => setShowResetAchievementsModal(true)}
theme="danger"
@@ -498,6 +512,7 @@ export function GameOptionsModal({
>
{t("reset_achievements")}
</Button>
)}
<Button
onClick={() => setShowChangePlaytimeModal(true)}
@@ -506,17 +521,21 @@ export function GameOptionsModal({
{t("update_game_playtime")}
</Button>
{game.shop !== "custom" && (
<Button
onClick={() => {
setShowDeleteModal(true);
}}
theme="danger"
disabled={
isGameDownloading || deleting || !game.download?.downloadPath
isGameDownloading ||
deleting ||
!game.download?.downloadPath
}
>
{t("remove_files")}
</Button>
)}
</div>
</div>
</div>

View File

@@ -1,3 +1,4 @@
export * from "./repacks-modal";
export * from "./download-settings-modal";
export * from "./game-options-modal";
export * from "./edit-game-modal";

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -1,4 +1,4 @@
export type GameShop = "steam" | "epic";
export type GameShop = "steam" | "epic" | "custom";
export type ShortcutLocation = "desktop" | "start_menu";

View File

@@ -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;