diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index ea7fff89..ce8b4de1 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -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", diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index b1714804..58235989 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -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": "Поиск", diff --git a/src/main/events/catalogue/save-game-shop-assets.ts b/src/main/events/catalogue/save-game-shop-assets.ts index f0ec4343..bf5f8b81 100644 --- a/src/main/events/catalogue/save-game-shop-assets.ts +++ b/src/main/events/catalogue/save-game-shop-assets.ts @@ -10,7 +10,16 @@ const saveGameShopAssets = async ( ): Promise => { 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); diff --git a/src/main/events/index.ts b/src/main/events/index.ts index 6bd74b69..d4c461f8 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -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"; diff --git a/src/main/events/library/add-custom-game-to-library.ts b/src/main/events/library/add-custom-game-to-library.ts new file mode 100644 index 00000000..47fd3436 --- /dev/null +++ b/src/main/events/library/add-custom-game-to-library.ts @@ -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); diff --git a/src/main/events/library/add-game-to-library.ts b/src/main/events/library/add-game-to-library.ts index 01495a39..4fdeae30 100644 --- a/src/main/events/library/add-game-to-library.ts +++ b/src/main/events/library/add-game-to-library.ts @@ -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); diff --git a/src/main/events/library/change-game-playtime.ts b/src/main/events/library/change-game-playtime.ts index ff37c33e..8ad252bd 100644 --- a/src/main/events/library/change-game-playtime.ts +++ b/src/main/events/library/change-game-playtime.ts @@ -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}`); } }; diff --git a/src/main/events/library/cleanup-unused-assets.ts b/src/main/events/library/cleanup-unused-assets.ts new file mode 100644 index 00000000..22490c07 --- /dev/null +++ b/src/main/events/library/cleanup-unused-assets.ts @@ -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 => { + 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> => { + // 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(); + + 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); diff --git a/src/main/events/library/copy-custom-game-asset.ts b/src/main/events/library/copy-custom-game-asset.ts new file mode 100644 index 00000000..07c3d6f7 --- /dev/null +++ b/src/main/events/library/copy-custom-game-asset.ts @@ -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 => { + 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); diff --git a/src/main/events/library/get-library.ts b/src/main/events/library/get-library.ts index ce859908..6314f83d 100644 --- a/src/main/events/library/get-library.ts +++ b/src/main/events/library/get-library.ts @@ -23,7 +23,10 @@ const getLibrary = async (): Promise => { ...game, download: download ?? null, ...gameAssets, - }; + // Ensure compatibility with LibraryGame type + libraryHeroImageUrl: + game.libraryHeroImageUrl ?? gameAssets?.libraryHeroImageUrl, + } as LibraryGame; }) ); }); diff --git a/src/main/events/library/update-custom-game.ts b/src/main/events/library/update-custom-game.ts new file mode 100644 index 00000000..6152c0df --- /dev/null +++ b/src/main/events/library/update-custom-game.ts @@ -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); diff --git a/src/main/events/library/update-game-custom-assets.ts b/src/main/events/library/update-game-custom-assets.ts new file mode 100644 index 00000000..866cd60e --- /dev/null +++ b/src/main/events/library/update-game-custom-assets.ts @@ -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); diff --git a/src/main/events/misc/delete-temp-file.ts b/src/main/events/misc/delete-temp-file.ts new file mode 100644 index 00000000..7ca88fa1 --- /dev/null +++ b/src/main/events/misc/delete-temp-file.ts @@ -0,0 +1,18 @@ +import fs from "node:fs"; +import { registerEvent } from "../register-event"; + +const deleteTempFile = async ( + _event: Electron.IpcMainInvokeEvent, + filePath: string +): Promise => { + 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); diff --git a/src/main/events/misc/save-temp-file.ts b/src/main/events/misc/save-temp-file.ts new file mode 100644 index 00000000..8f253bf2 --- /dev/null +++ b/src/main/events/misc/save-temp-file.ts @@ -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 => { + 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); diff --git a/src/main/events/torrenting/start-game-download.ts b/src/main/events/torrenting/start-game-download.ts index 00281973..8216b519 100644 --- a/src/main/events/torrenting/start-game-download.ts +++ b/src/main/events/torrenting/start-game-download.ts @@ -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, diff --git a/src/main/index.ts b/src/main/index.ts index af197a6b..106feaf0 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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 = ` + + + + + + + + + + `; + + return new Response(svgContent, { + headers: { "Content-Type": "image/svg+xml" }, + }); + }); + await loadState(); const language = await db diff --git a/src/main/services/library-sync/merge-with-remote-games.ts b/src/main/services/library-sync/merge-with-remote-games.ts index 152e1138..b5b2d551 100644 --- a/src/main/services/library-sync/merge-with-remote-games.ts +++ b/src/main/services/library-sync/merge-with-remote-games.ts @@ -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, diff --git a/src/main/services/library-sync/upload-games-batch.ts b/src/main/services/library-sync/upload-games-batch.ts index d4febfea..f0af90ba 100644 --- a/src/main/services/library-sync/upload-games-batch.ts +++ b/src/main/services/library-sync/upload-games-batch.ts @@ -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" ); }); diff --git a/src/preload/index.ts b/src/preload/index.ts index ca275c91..e536f8c7 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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, diff --git a/src/renderer/src/assets/play-logo.svg b/src/renderer/src/assets/play-logo.svg new file mode 100644 index 00000000..51ecaa28 --- /dev/null +++ b/src/renderer/src/assets/play-logo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/renderer/src/components/sidebar/sidebar-adding-custom-game-modal.scss b/src/renderer/src/components/sidebar/sidebar-adding-custom-game-modal.scss new file mode 100644 index 00000000..942384fe --- /dev/null +++ b/src/renderer/src/components/sidebar/sidebar-adding-custom-game-modal.scss @@ -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); + } +} diff --git a/src/renderer/src/components/sidebar/sidebar-adding-custom-game-modal.tsx b/src/renderer/src/components/sidebar/sidebar-adding-custom-game-modal.tsx new file mode 100644 index 00000000..f50bd814 --- /dev/null +++ b/src/renderer/src/components/sidebar/sidebar-adding-custom-game-modal.tsx @@ -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) { + 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) => { + 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 ( + +
+
+ + + {t("custom_game_modal_browse")} + + } + /> + + +
+ +
+ + +
+
+
+ ); +} diff --git a/src/renderer/src/components/sidebar/sidebar-game-item.tsx b/src/renderer/src/components/sidebar/sidebar-game-item.tsx index 0672f847..7733aee0 100644 --- a/src/renderer/src/components/sidebar/sidebar-game-item.tsx +++ b/src/renderer/src/components/sidebar/sidebar-game-item.tsx @@ -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) { 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 ; + } + return ; + }; + return (
  • handleSidebarGameClick(event, game)} > - {game.iconUrl ? ( + {sidebarIcon ? ( {game.title} ) : ( - + getFallbackIcon() )} diff --git a/src/renderer/src/components/sidebar/sidebar.scss b/src/renderer/src/components/sidebar/sidebar.scss index 45b3f598..0fec7c30 100644 --- a/src/renderer/src/components/sidebar/sidebar.scss +++ b/src/renderer/src/components/sidebar/sidebar.scss @@ -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; + } + } } diff --git a/src/renderer/src/components/sidebar/sidebar.tsx b/src/renderer/src/components/sidebar/sidebar.tsx index 42e87e32..f77066a2 100644 --- a/src/renderer/src/components/sidebar/sidebar.tsx +++ b/src/renderer/src/components/sidebar/sidebar.tsx @@ -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() { {t("my_library")} - + + + + + + + + ); } diff --git a/src/renderer/src/context/game-details/game-details.context.tsx b/src/renderer/src/context/game-details/game-details.context.tsx index 864fd482..6fe01663 100644 --- a/src/renderer/src/context/game-details/game-details.context.tsx +++ b/src/renderer/src/context/game-details/game-details.context.tsx @@ -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 = diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index 87b2d63d..81d18940 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -112,6 +112,37 @@ declare global { objectId: string, title: string ) => Promise; + addCustomGameToLibrary: ( + title: string, + executablePath: string, + iconUrl?: string, + logoImageUrl?: string, + libraryHeroImageUrl?: string + ) => Promise; + updateCustomGame: ( + shop: GameShop, + objectId: string, + title: string, + iconUrl?: string, + logoImageUrl?: string, + libraryHeroImageUrl?: string + ) => Promise; + copyCustomGameAsset: ( + sourcePath: string, + assetType: "icon" | "logo" | "hero" + ) => Promise; + cleanupUnusedAssets: () => Promise<{ + deletedCount: number; + errors: string[]; + }>; + updateGameCustomAssets: ( + shop: GameShop, + objectId: string, + title: string, + customIconUrl?: string | null, + customLogoImageUrl?: string | null, + customHeroImageUrl?: string | null + ) => Promise; 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; + deleteTempFile: (filePath: string) => Promise; platform: NodeJS.Platform; /* Auto update */ diff --git a/src/renderer/src/helpers.ts b/src/renderer/src/helpers.ts index eb7cebb0..01b4d6cc 100644 --- a/src/renderer/src/helpers.ts +++ b/src/renderer/src/helpers.ts @@ -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 = ` + + + + + + + + `; + + // Return as data URL that works in img tags + return `data:image/svg+xml;base64,${btoa(svgContent)}`; +}; diff --git a/src/renderer/src/pages/downloads/delete-game-modal.tsx b/src/renderer/src/pages/downloads/delete-game-modal.tsx index 7f4a530e..b8b4d8a6 100644 --- a/src/renderer/src/pages/downloads/delete-game-modal.tsx +++ b/src/renderer/src/pages/downloads/delete-game-modal.tsx @@ -28,12 +28,12 @@ export function DeleteGameModal({ onClose={onClose} >
    - -
    diff --git a/src/renderer/src/pages/game-details/description-header/description-header.scss b/src/renderer/src/pages/game-details/description-header/description-header.scss index a6faaee3..920e8068 100644 --- a/src/renderer/src/pages/game-details/description-header/description-header.scss +++ b/src/renderer/src/pages/game-details/description-header/description-header.scss @@ -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; diff --git a/src/renderer/src/pages/game-details/gallery-slider/gallery-slider.scss b/src/renderer/src/pages/game-details/gallery-slider/gallery-slider.scss index c7932a34..f66da32b 100644 --- a/src/renderer/src/pages/game-details/gallery-slider/gallery-slider.scss +++ b/src/renderer/src/pages/game-details/gallery-slider/gallery-slider.scss @@ -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; diff --git a/src/renderer/src/pages/game-details/game-details-content.tsx b/src/renderer/src/pages/game-details/game-details-content.tsx index 1ce80da4..347e5a1c 100644 --- a/src/renderer/src/pages/game-details/game-details-content.tsx +++ b/src/renderer/src/pages/game-details/game-details-content.tsx @@ -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 ( + {game?.title} + ); + } else { + return ( +
    {game?.title}
    + ); + } + } else { + // For non-custom games, show logo image if available + return logoImage ? ( + {game?.title} + ) : null; + } + }; + return (
    {game?.title} @@ -95,26 +165,35 @@ export function GameDetailsContent() { style={{ opacity: backdropOpacity }} >
    - {game?.title} + {renderGameLogo()} - +
    + + + {game?.shop !== "custom" && ( + + )} +
    @@ -134,9 +213,17 @@ export function GameDetailsContent() { /> - + {game?.shop !== "custom" && } + + setShowEditGameModal(false)} + game={game} + shopDetails={shopDetails} + onGameUpdated={handleGameUpdated} + /> ); } diff --git a/src/renderer/src/pages/game-details/game-details.scss b/src/renderer/src/pages/game-details/game-details.scss index e488db5f..786a8d30 100644 --- a/src/renderer/src/pages/game-details/game-details.scss +++ b/src/renderer/src/pages/game-details/game-details.scss @@ -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; diff --git a/src/renderer/src/pages/game-details/game-details.tsx b/src/renderer/src/pages/game-details/game-details.tsx index b966e6e7..f0778494 100644 --- a/src/renderer/src/pages/game-details/game-details.tsx +++ b/src/renderer/src/pages/game-details/game-details.tsx @@ -178,6 +178,7 @@ export default function GameDetails() { onClose={() => { setShowGameOptionsModal(false); }} + onNavigateHome={() => navigate("/")} /> )} diff --git a/src/renderer/src/pages/game-details/hero/hero-panel-actions.tsx b/src/renderer/src/pages/game-details/hero/hero-panel-actions.tsx index 307de108..d8d98583 100644 --- a/src/renderer/src/pages/game-details/hero/hero-panel-actions.tsx +++ b/src/renderer/src/pages/game-details/hero/hero-panel-actions.tsx @@ -229,7 +229,7 @@ export function HeroPanelActions() { {game.favorite ? : } - {userDetails && ( + {userDetails && game.shop !== "custom" && ( + - - diff --git a/src/renderer/src/pages/game-details/modals/edit-game-modal.scss b/src/renderer/src/pages/game-details/modals/edit-game-modal.scss new file mode 100644 index 00000000..5400df07 --- /dev/null +++ b/src/renderer/src/pages/game-details/modals/edit-game-modal.scss @@ -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; +} diff --git a/src/renderer/src/pages/game-details/modals/edit-game-modal.tsx b/src/renderer/src/pages/game-details/modals/edit-game-modal.tsx new file mode 100644 index 00000000..04a27779 --- /dev/null +++ b/src/renderer/src/pages/game-details/modals/edit-game-modal.tsx @@ -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) { + 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("icon"); + + const [defaultIconUrl, setDefaultIconUrl] = useState(null); + const [defaultLogoUrl, setDefaultLogoUrl] = useState(null); + const [defaultHeroUrl, setDefaultHeroUrl] = useState(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) => { + 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(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 ( +
    + + + {game && !isCustomGame(game) && assetPath && ( + + )} +
    + } + /> +
    + {t(getResolutionKey())} +
    + + {hasImage && ( + + )} + + {!hasImage && ( + + )} + + ); + }; + + return ( + +
    +
    + + +
    +
    + {t("edit_game_modal_assets")} +
    +
    + + + +
    +
    + + {renderImageSection(selectedAssetType)} +
    + +
    + + +
    +
    +
    + ); +} diff --git a/src/renderer/src/pages/game-details/modals/game-options-modal.tsx b/src/renderer/src/pages/game-details/modals/game-options-modal.tsx index 9c20acce..e658fbb8 100644 --- a/src/renderer/src/pages/game-details/modals/game-options-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/game-options-modal.tsx @@ -18,12 +18,14 @@ export interface GameOptionsModalProps { visible: boolean; game: LibraryGame; onClose: () => void; + onNavigateHome?: () => void; } export function GameOptionsModal({ visible, game, onClose, + onNavigateHome, }: Readonly) { 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")} - + {game.shop !== "custom" && ( + + )} {shouldShowCreateStartMenuShortcut && ( - {game.download?.downloadPath && ( +
    - )} + {game.download?.downloadPath && ( + + )} +
    - + )}
    @@ -486,18 +499,20 @@ export function GameOptionsModal({ {t("remove_from_library")} - + {game.shop !== "custom" && ( + + )} - + {game.shop !== "custom" && ( + + )}
    diff --git a/src/renderer/src/pages/game-details/modals/index.ts b/src/renderer/src/pages/game-details/modals/index.ts index e02421b9..724e0003 100644 --- a/src/renderer/src/pages/game-details/modals/index.ts +++ b/src/renderer/src/pages/game-details/modals/index.ts @@ -1,3 +1,4 @@ export * from "./repacks-modal"; export * from "./download-settings-modal"; export * from "./game-options-modal"; +export * from "./edit-game-modal"; diff --git a/src/renderer/src/pages/game-details/modals/remove-from-library-modal.tsx b/src/renderer/src/pages/game-details/modals/remove-from-library-modal.tsx index 85cea8cd..eb421ec7 100644 --- a/src/renderer/src/pages/game-details/modals/remove-from-library-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/remove-from-library-modal.tsx @@ -31,12 +31,12 @@ export function RemoveGameFromLibraryModal({ onClose={onClose} >
    - -
    diff --git a/src/renderer/src/pages/game-details/modals/reset-achievements-modal.tsx b/src/renderer/src/pages/game-details/modals/reset-achievements-modal.tsx index fc71e2d0..b6eb38a2 100644 --- a/src/renderer/src/pages/game-details/modals/reset-achievements-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/reset-achievements-modal.tsx @@ -36,12 +36,12 @@ export function ResetAchievementsModal({ })} >
    - -
    diff --git a/src/renderer/src/pages/settings/aparence/modals/delete-all-themes-modal.tsx b/src/renderer/src/pages/settings/aparence/modals/delete-all-themes-modal.tsx index be46e56a..9439d273 100644 --- a/src/renderer/src/pages/settings/aparence/modals/delete-all-themes-modal.tsx +++ b/src/renderer/src/pages/settings/aparence/modals/delete-all-themes-modal.tsx @@ -38,12 +38,12 @@ export const DeleteAllThemesModal = ({ onClose={onClose} >
    - -
    diff --git a/src/renderer/src/pages/settings/aparence/modals/delete-theme-modal.tsx b/src/renderer/src/pages/settings/aparence/modals/delete-theme-modal.tsx index 5783ce01..c1a5a1e0 100644 --- a/src/renderer/src/pages/settings/aparence/modals/delete-theme-modal.tsx +++ b/src/renderer/src/pages/settings/aparence/modals/delete-theme-modal.tsx @@ -41,12 +41,12 @@ export const DeleteThemeModal = ({ onClose={onClose} >
    - -
    diff --git a/src/renderer/src/pages/settings/aparence/modals/import-theme-modal.tsx b/src/renderer/src/pages/settings/aparence/modals/import-theme-modal.tsx index db4abe8c..601e9568 100644 --- a/src/renderer/src/pages/settings/aparence/modals/import-theme-modal.tsx +++ b/src/renderer/src/pages/settings/aparence/modals/import-theme-modal.tsx @@ -77,12 +77,12 @@ export const ImportThemeModal = ({ onClose={onClose} >
    - -
    diff --git a/src/types/game.types.ts b/src/types/game.types.ts index cc19f09c..ed8fb852 100644 --- a/src/types/game.types.ts +++ b/src/types/game.types.ts @@ -1,4 +1,4 @@ -export type GameShop = "steam" | "epic"; +export type GameShop = "steam" | "epic" | "custom"; export type ShortcutLocation = "desktop" | "start_menu"; diff --git a/src/types/level.types.ts b/src/types/level.types.ts index c5bd3454..73fce370 100644 --- a/src/types/level.types.ts +++ b/src/types/level.types.ts @@ -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;