From 7e59e02d033bccc7f83fbe0577e40848760749c8 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Fri, 19 Sep 2025 16:18:49 +0300 Subject: [PATCH 01/22] Feat: Custom Games --- src/locales/en/translation.json | 55 +++- src/locales/ru/translation.json | 53 ++- src/main/events/index.ts | 2 + .../library/add-custom-game-to-library.ts | 68 ++++ .../events/library/change-game-playtime.ts | 12 +- src/main/events/library/update-custom-game.ts | 53 +++ src/main/index.ts | 48 +++ .../library-sync/merge-with-remote-games.ts | 2 + .../library-sync/upload-games-batch.ts | 2 +- src/preload/index.ts | 19 ++ src/renderer/src/assets/play-logo.svg | 4 + .../sidebar-adding-custom-game-modal.scss | 52 +++ .../sidebar-adding-custom-game-modal.tsx | 183 +++++++++++ .../components/sidebar/sidebar-game-item.tsx | 12 +- .../src/components/sidebar/sidebar.scss | 20 ++ .../src/components/sidebar/sidebar.tsx | 54 ++- .../game-details/game-details.context.tsx | 6 + src/renderer/src/declaration.d.ts | 15 + src/renderer/src/helpers.ts | 20 ++ .../game-details/game-details-content.tsx | 98 ++++-- .../src/pages/game-details/game-details.scss | 37 +++ .../hero/hero-panel-playtime.scss | 1 + .../game-details/hero/hero-panel-playtime.tsx | 18 +- .../modals/edit-custom-game-modal.scss | 46 +++ .../modals/edit-custom-game-modal.tsx | 311 ++++++++++++++++++ .../src/pages/game-details/modals/index.ts | 1 + src/types/game.types.ts | 2 +- src/types/level.types.ts | 2 + 28 files changed, 1145 insertions(+), 51 deletions(-) create mode 100644 src/main/events/library/add-custom-game-to-library.ts create mode 100644 src/main/events/library/update-custom-game.ts create mode 100644 src/renderer/src/assets/play-logo.svg create mode 100644 src/renderer/src/components/sidebar/sidebar-adding-custom-game-modal.scss create mode 100644 src/renderer/src/components/sidebar/sidebar-adding-custom-game-modal.tsx create mode 100644 src/renderer/src/pages/game-details/modals/edit-custom-game-modal.scss create mode 100644 src/renderer/src/pages/game-details/modals/edit-custom-game-modal.tsx diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 9f8de8f8..86ef567b 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -28,7 +28,60 @@ "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_game_name": "Game Name", + "custom_game_modal_enter_name": "Enter game name", + "custom_game_modal_image": "Game Image", + "custom_game_modal_select_image": "Select game image", + "custom_game_modal_image_preview": "Game image preview", + "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_fill_required": "Please fill in all required fields", + "custom_game_modal_success": "Custom game added successfully", + "custom_game_modal_failed": "Failed to add custom game", + "custom_game_modal_executable": "Executable", + "custom_game_modal_image_filter": "Image", + "custom_game_modal_icon": "Game Icon", + "custom_game_modal_select_icon": "Select game icon", + "custom_game_modal_icon_preview": "Game icon preview", + "custom_game_modal_logo": "Game Logo", + "custom_game_modal_select_logo": "Select game logo", + "custom_game_modal_logo_preview": "Game logo preview", + "custom_game_modal_hero": "Library Hero Image", + "custom_game_modal_select_hero": "Select library hero image", + "custom_game_modal_hero_preview": "Library hero image preview", + "edit_custom_game_modal": "Edit Custom Game", + "edit_custom_game_modal_description": "Edit your custom game details", + "edit_custom_game_modal_game_name": "Game Name", + "edit_custom_game_modal_enter_name": "Enter game name", + "edit_custom_game_modal_image": "Game Image", + "edit_custom_game_modal_select_image": "Select game image", + "edit_custom_game_modal_browse": "Browse", + "edit_custom_game_modal_image_preview": "Game image preview", + "edit_custom_game_modal_icon": "Game Icon", + "edit_custom_game_modal_select_icon": "Select game icon", + "edit_custom_game_modal_icon_preview": "Game icon preview", + "edit_custom_game_modal_logo": "Game Logo", + "edit_custom_game_modal_select_logo": "Select game logo", + "edit_custom_game_modal_logo_preview": "Game logo preview", + "edit_custom_game_modal_hero": "Library Hero Image", + "edit_custom_game_modal_select_hero": "Select library hero image", + "edit_custom_game_modal_hero_preview": "Library hero image preview", + "edit_custom_game_modal_cancel": "Cancel", + "edit_custom_game_modal_update": "Update Game", + "edit_custom_game_modal_updating": "Updating Game...", + "edit_custom_game_modal_fill_required": "Please fill in all required fields", + "edit_custom_game_modal_success": "Custom game updated successfully", + "edit_custom_game_modal_failed": "Failed to update custom game", + "edit_custom_game_modal_image_filter": "Image" }, "header": { "search": "Search games", diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index e576e5d9..ae8f2b9d 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -28,7 +28,58 @@ "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_game_name": "Название игры", + "custom_game_modal_enter_name": "Введите название игры", + "custom_game_modal_image": "Изображение игры", + "custom_game_modal_select_image": "Выберите изображение игры", + "custom_game_modal_image_preview": "Предварительный просмотр изображения игры", + "custom_game_modal_browse": "Обзор", + "custom_game_modal_cancel": "Отмена", + "custom_game_modal_add": "Добавить игру", + "custom_game_modal_adding": "Добавление игры...", + "custom_game_modal_fill_required": "Пожалуйста, заполните все обязательные поля", + "custom_game_modal_success": "Пользовательская игра успешно добавлена", + "custom_game_modal_failed": "Не удалось добавить пользовательскую игру", + "custom_game_modal_executable": "Исполняемый файл", + "custom_game_modal_image_filter": "Изображение", + "custom_game_modal_icon": "Иконка игры", + "custom_game_modal_select_icon": "Выберите иконку игры", + "custom_game_modal_icon_preview": "Предпросмотр иконки игры", + "custom_game_modal_logo": "Логотип игры", + "custom_game_modal_select_logo": "Выберите логотип игры", + "custom_game_modal_logo_preview": "Предпросмотр логотипа игры", + "custom_game_modal_hero": "Изображение героя библиотеки", + "custom_game_modal_select_hero": "Выберите изображение героя библиотеки", + "custom_game_modal_hero_preview": "Предпросмотр изображения героя библиотеки", + "edit_custom_game_modal": "Редактировать пользовательскую игру", + "edit_custom_game_modal_description": "Редактируйте детали вашей пользовательской игры", + "edit_custom_game_modal_game_name": "Название игры", + "edit_custom_game_modal_enter_name": "Введите название игры", + "edit_custom_game_modal_image": "Изображение игры", + "edit_custom_game_modal_select_image": "Выберите изображение игры", + "edit_custom_game_modal_browse": "Обзор", + "edit_custom_game_modal_image_preview": "Предпросмотр изображения игры", + "edit_custom_game_modal_icon": "Иконка игры", + "edit_custom_game_modal_select_icon": "Выберите иконку игры", + "edit_custom_game_modal_icon_preview": "Предпросмотр иконки игры", + "edit_custom_game_modal_logo": "Логотип игры", + "edit_custom_game_modal_select_logo": "Выберите логотип игры", + "edit_custom_game_modal_logo_preview": "Предпросмотр логотипа игры", + "edit_custom_game_modal_hero": "Изображение героя библиотеки", + "edit_custom_game_modal_select_hero": "Выберите изображение героя библиотеки", + "edit_custom_game_modal_hero_preview": "Предпросмотр изображения героя библиотеки", + "edit_custom_game_modal_cancel": "Отмена", + "edit_custom_game_modal_update": "Обновить игру", + "edit_custom_game_modal_updating": "Обновление игры...", + "edit_custom_game_modal_fill_required": "Пожалуйста, заполните все обязательные поля", + "edit_custom_game_modal_success": "Пользовательская игра успешно обновлена", + "edit_custom_game_modal_failed": "Не удалось обновить пользовательскую игру", + "edit_custom_game_modal_image_filter": "Изображение" }, "header": { "search": "Поиск", diff --git a/src/main/events/index.ts b/src/main/events/index.ts index 9765b517..ea7597ad 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -14,6 +14,8 @@ 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/add-game-to-favorites"; import "./library/remove-game-from-favorites"; import "./library/create-game-shortcut"; 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..382328a5 --- /dev/null +++ b/src/main/events/library/add-custom-game-to-library.ts @@ -0,0 +1,68 @@ +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); \ No newline at end of file diff --git a/src/main/events/library/change-game-playtime.ts b/src/main/events/library/change-game-playtime.ts index ff37c33e..20da70ec 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/update-custom-game.ts b/src/main/events/library/update-custom-game.ts new file mode 100644 index 00000000..4ff858a2 --- /dev/null +++ b/src/main/events/library/update-custom-game.ts @@ -0,0 +1,53 @@ +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); \ No newline at end of file diff --git a/src/main/index.ts b/src/main/index.ts index af197a6b..af9aa676 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -64,6 +64,54 @@ app.whenReady().then(async () => { return net.fetch(url.pathToFileURL(decodeURI(filePath)).toString()); }); + protocol.handle("gradient", (request) => { + const gradientCss = decodeURIComponent(request.url.slice("gradient:".length)); + + const match = gradientCss.match(/linear-gradient\(([^,]+),\s*([^,]+),\s*([^)]+)\)/); + + let direction = "45deg"; + let color1 = '#4a90e2'; + let color2 = '#7b68ee'; + + if (match) { + direction = match[1].trim(); + color1 = match[2].trim(); + color2 = match[3].trim(); + } + + let x1 = "0%", y1 = "0%", x2 = "100%", y2 = "100%"; + + if (direction === "to right") { + x1 = "0%"; y1 = "0%"; x2 = "100%"; y2 = "0%"; + } else if (direction === "to bottom") { + x1 = "0%"; y1 = "0%"; x2 = "0%"; y2 = "100%"; + } else if (direction === "45deg") { + x1 = "0%"; y1 = "100%"; x2 = "100%"; y2 = "0%"; + } else if (direction === "135deg") { + x1 = "0%"; y1 = "0%"; x2 = "100%"; y2 = "100%"; + } else if (direction === "225deg") { + x1 = "100%"; y1 = "0%"; x2 = "0%"; y2 = "100%"; + } else if (direction === "315deg") { + x1 = "100%"; y1 = "100%"; x2 = "0%"; y2 = "0%"; + } + + 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 a35414ac..c5bbe144 100644 --- a/src/main/services/library-sync/merge-with-remote-games.ts +++ b/src/main/services/library-sync/merge-with-remote-games.ts @@ -44,6 +44,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, diff --git a/src/main/services/library-sync/upload-games-batch.ts b/src/main/services/library-sync/upload-games-batch.ts index 837fb48a..10d2c8dd 100644 --- a/src/main/services/library-sync/upload-games-batch.ts +++ b/src/main/services/library-sync/upload-games-batch.ts @@ -11,7 +11,7 @@ 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 d29417b0..b9ad8d98 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -128,6 +128,23 @@ 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), + updateCustomGame: ( + shop: GameShop, + objectId: string, + title: string, + iconUrl?: string, + logoImageUrl?: string, + libraryHeroImageUrl?: string + ) => + ipcRenderer.invoke("updateCustomGame", shop, objectId, title, iconUrl, logoImageUrl, libraryHeroImageUrl), createGameShortcut: ( shop: GameShop, objectId: string, @@ -476,4 +493,6 @@ contextBridge.exposeInMainWorld("electron", { }, closeEditorWindow: (themeId?: string) => ipcRenderer.invoke("closeEditorWindow", themeId), + + }); 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..48b84da3 --- /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); + } +} \ No newline at end of file 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..771c5464 --- /dev/null +++ b/src/renderer/src/components/sidebar/sidebar-adding-custom-game-modal.tsx @@ -0,0 +1,183 @@ +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, +}: 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) => { + 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")} + + } + /> + + + + + + +
+ +
+ + +
+
+
+ ); +} \ No newline at end of file diff --git a/src/renderer/src/components/sidebar/sidebar-game-item.tsx b/src/renderer/src/components/sidebar/sidebar-game-item.tsx index 0672f847..4f905c59 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,11 @@ export function SidebarGameItem({ }: Readonly) { const location = useLocation(); + const isCustomGame = game.shop === "custom"; + const sidebarIcon = isCustomGame + ? game.libraryImageUrl || game.iconUrl + : game.iconUrl; + return (
  • handleSidebarGameClick(event, game)} > - {game.iconUrl ? ( + {sidebarIcon ? ( {game.title} + ) : isCustomGame ? ( + ) : ( )} 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..a7d946a2 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,9 @@ 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 +65,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 +265,30 @@ 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 ce2923b2..81abfa13 100644 --- a/src/renderer/src/context/game-details/game-details.context.tsx +++ b/src/renderer/src/context/game-details/game-details.context.tsx @@ -201,6 +201,12 @@ export function GameDetailsContextProvider({ dispatch(setHeaderTitle(gameTitle)); }, [objectId, gameTitle, dispatch]); + useEffect(() => { + if (game?.title && game.shop === "custom") { + dispatch(setHeaderTitle(game.title)); + } + }, [game?.title, game?.shop, 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 0744884c..f66bd53e 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -111,6 +111,21 @@ 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; createGameShortcut: ( shop: GameShop, objectId: string, diff --git a/src/renderer/src/helpers.ts b/src/renderer/src/helpers.ts index eb7cebb0..7c45bafd 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/game-details/game-details-content.tsx b/src/renderer/src/pages/game-details/game-details-content.tsx index 40436614..ba3fb7bd 100644 --- a/src/renderer/src/pages/game-details/game-details-content.tsx +++ b/src/renderer/src/pages/game-details/game-details-content.tsx @@ -1,18 +1,20 @@ import { useContext, useEffect, useMemo, useRef, useState } from "react"; import { average } from "color.js"; import Color from "color"; +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 { EditCustomGameModal } 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"; @@ -28,11 +30,13 @@ export function GameDetailsContent() { gameColor, setGameColor, hasNSFWContentBlocked, + updateGame, } = useContext(gameDetailsContext); const { showHydraCloudModal } = useSubscription(); const { userDetails, hasActiveSubscription } = useUserDetails(); + const { updateLibrary } = useLibrary(); const { setShowCloudSyncModal, getGameArtifacts } = useContext(cloudSyncContext); @@ -53,10 +57,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 [showEditCustomGameModal, setShowEditCustomGameModal] = useState(false); const handleHeroLoad = async () => { const output = await average( @@ -92,10 +101,27 @@ export function GameDetailsContent() { setShowCloudSyncModal(true); }; + const handleEditCustomGameClick = () => { + setShowEditCustomGameModal(true); + }; + + const handleGameUpdated = (_updatedGame: any) => { + updateGame(); + updateLibrary(); + }; + useEffect(() => { getGameArtifacts(); }, [getGameArtifacts]); + const isCustomGame = game?.shop === "custom"; + const heroImage = isCustomGame + ? game?.libraryHeroImageUrl || game?.iconUrl || "" + : shopDetails?.assets?.libraryHeroImageUrl || ""; + const logoImage = isCustomGame + ? game?.logoImageUrl || "" // Don't use icon as fallback for custom games + : shopDetails?.assets?.logoImageUrl || ""; + return (
    {game?.title}
    - {game?.title} + {logoImage && ( + {game?.title} + )} - +
    + {game?.shop === "custom" && ( + + )} + + {game?.shop !== "custom" && ( + + )} +
    @@ -160,9 +203,18 @@ export function GameDetailsContent() { /> - + {game?.shop !== "custom" && } + + {game?.shop === "custom" && ( + setShowEditCustomGameModal(false)} + game={game} + 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 899d654a..46c0ae51 100644 --- a/src/renderer/src/pages/game-details/game-details.scss +++ b/src/renderer/src/pages/game-details/game-details.scss @@ -52,6 +52,43 @@ $hero-height: 300px; align-items: flex-end; } + &__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 { width: 100%; height: 100%; diff --git a/src/renderer/src/pages/game-details/hero/hero-panel-playtime.scss b/src/renderer/src/pages/game-details/hero/hero-panel-playtime.scss index 00142d59..9429ef21 100644 --- a/src/renderer/src/pages/game-details/hero/hero-panel-playtime.scss +++ b/src/renderer/src/pages/game-details/hero/hero-panel-playtime.scss @@ -17,6 +17,7 @@ display: flex; align-items: center; gap: 8px; + width: fit-content; } &__manual-warning { diff --git a/src/renderer/src/pages/game-details/hero/hero-panel-playtime.tsx b/src/renderer/src/pages/game-details/hero/hero-panel-playtime.tsx index 9cd246be..270ed030 100644 --- a/src/renderer/src/pages/game-details/hero/hero-panel-playtime.tsx +++ b/src/renderer/src/pages/game-details/hero/hero-panel-playtime.tsx @@ -11,7 +11,6 @@ import "./hero-panel-playtime.scss"; export function HeroPanelPlaytime() { const [lastTimePlayed, setLastTimePlayed] = useState(""); - const { game, isGameRunning } = useContext(gameDetailsContext); const { t } = useTranslation("game_details"); @@ -89,19 +88,23 @@ export function HeroPanelPlaytime() { return ( <> -

    {game.hasManuallyUpdatedPlaytime && ( - )} @@ -119,7 +122,7 @@ export function HeroPanelPlaytime() { })}

    )} - + {game.hasManuallyUpdatedPlaytime && ( )} diff --git a/src/renderer/src/pages/game-details/modals/edit-custom-game-modal.scss b/src/renderer/src/pages/game-details/modals/edit-custom-game-modal.scss new file mode 100644 index 00000000..6f24d4ef --- /dev/null +++ b/src/renderer/src/pages/game-details/modals/edit-custom-game-modal.scss @@ -0,0 +1,46 @@ +@use "../../../scss/globals.scss"; + +.edit-custom-game-modal { + &__container { + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit * 2); + } + + &__form { + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit * 2); + } + + &__image-section { + display: flex; + flex-direction: column; + gap: globals.$spacing-unit; + } + + &__image-preview { + display: flex; + justify-content: center; + align-items: center; + padding: globals.$spacing-unit; + border: 1px dashed globals.$border-color; + border-radius: 8px; + background-color: rgba(255, 255, 255, 0.05); + } + + &__preview-image { + max-width: 120px; + max-height: 80px; + border-radius: 8px; + object-fit: cover; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); + } + + &__actions { + display: flex; + gap: globals.$spacing-unit; + justify-content: flex-end; + margin-top: globals.$spacing-unit; + } +} \ No newline at end of file diff --git a/src/renderer/src/pages/game-details/modals/edit-custom-game-modal.tsx b/src/renderer/src/pages/game-details/modals/edit-custom-game-modal.tsx new file mode 100644 index 00000000..a489b88c --- /dev/null +++ b/src/renderer/src/pages/game-details/modals/edit-custom-game-modal.tsx @@ -0,0 +1,311 @@ +import { useState, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { ImageIcon } from "@primer/octicons-react"; + +import { Modal, TextField, Button } from "@renderer/components"; +import { useToast } from "@renderer/hooks"; +import type { Game } from "@types"; + +import "./edit-custom-game-modal.scss"; + +export interface EditCustomGameModalProps { + visible: boolean; + onClose: () => void; + game: Game; + onGameUpdated: (updatedGame: Game) => void; +} + +export function EditCustomGameModal({ + visible, + onClose, + game, + onGameUpdated, +}: EditCustomGameModalProps) { + 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); + + useEffect(() => { + if (game && visible) { + setGameName(game.title || ""); + + const currentIconPath = game.iconUrl?.startsWith("local:") + ? game.iconUrl.replace("local:", "") + : ""; + const currentLogoPath = game.logoImageUrl?.startsWith("local:") + ? game.logoImageUrl.replace("local:", "") + : ""; + const currentHeroPath = game.libraryHeroImageUrl?.startsWith("local:") + ? game.libraryHeroImageUrl.replace("local:", "") + : ""; + + setIconPath(currentIconPath); + setLogoPath(currentLogoPath); + setHeroPath(currentHeroPath); + } + }, [game, visible]); + + const handleGameNameChange = (event: React.ChangeEvent) => { + setGameName(event.target.value); + }; + + const handleSelectIcon = async () => { + const { filePaths } = await window.electron.showOpenDialog({ + properties: ["openFile"], + filters: [ + { + name: t("edit_custom_game_modal_image_filter"), + extensions: ["jpg", "jpeg", "png", "gif", "webp"], + }, + ], + }); + + if (filePaths && filePaths.length > 0) { + setIconPath(filePaths[0]); + } + }; + + const handleSelectLogo = async () => { + const { filePaths } = await window.electron.showOpenDialog({ + properties: ["openFile"], + filters: [ + { + name: t("edit_custom_game_modal_image_filter"), + extensions: ["jpg", "jpeg", "png", "gif", "webp"], + }, + ], + }); + + if (filePaths && filePaths.length > 0) { + setLogoPath(filePaths[0]); + } + }; + + const handleSelectHero = async () => { + const { filePaths } = await window.electron.showOpenDialog({ + properties: ["openFile"], + filters: [ + { + name: t("edit_custom_game_modal_image_filter"), + extensions: ["jpg", "jpeg", "png", "gif", "webp"], + }, + ], + }); + + if (filePaths && filePaths.length > 0) { + setHeroPath(filePaths[0]); + } + }; + + const handleUpdateGame = async () => { + if (!gameName.trim()) { + showErrorToast(t("edit_custom_game_modal_fill_required")); + return; + } + + setIsUpdating(true); + + try { + // Preserve existing image URLs if not changed + const iconUrl = iconPath ? `local:${iconPath}` : game.iconUrl; + const logoImageUrl = logoPath ? `local:${logoPath}` : game.logoImageUrl; + const libraryHeroImageUrl = heroPath ? `local:${heroPath}` : game.libraryHeroImageUrl; + + const updatedGame = await window.electron.updateCustomGame( + game.shop, + game.objectId, + gameName.trim(), + iconUrl || undefined, + logoImageUrl || undefined, + libraryHeroImageUrl || undefined + ); + + showSuccessToast(t("edit_custom_game_modal_success")); + onGameUpdated(updatedGame); + onClose(); + } catch (error) { + console.error("Failed to update custom game:", error); + showErrorToast( + error instanceof Error + ? error.message + : t("edit_custom_game_modal_failed") + ); + } finally { + setIsUpdating(false); + } + }; + + const handleClose = () => { + if (!isUpdating) { + setGameName(game?.title || ""); + + const currentIconPath = game?.iconUrl?.startsWith("local:") + ? game.iconUrl.replace("local:", "") + : ""; + const currentLogoPath = game?.logoImageUrl?.startsWith("local:") + ? game.logoImageUrl.replace("local:", "") + : ""; + const currentHeroPath = game?.libraryHeroImageUrl?.startsWith("local:") + ? game.libraryHeroImageUrl.replace("local:", "") + : ""; + + setIconPath(currentIconPath); + setLogoPath(currentLogoPath); + setHeroPath(currentHeroPath); + onClose(); + } + }; + + const isFormValid = gameName.trim(); + + const getIconPreviewUrl = () => { + return iconPath ? `local:${iconPath}` : null; + }; + + const getLogoPreviewUrl = () => { + return logoPath ? `local:${logoPath}` : null; + }; + + const getHeroPreviewUrl = () => { + return heroPath ? `local:${heroPath}` : null; + }; + + return ( + +
    +
    + + +
    + + + {t("edit_custom_game_modal_browse")} + + } + /> + + {iconPath && ( +
    + {t("edit_custom_game_modal_icon_preview")} +
    + )} +
    + +
    + + + {t("edit_custom_game_modal_browse")} + + } + /> + + {logoPath && ( +
    + {t("edit_custom_game_modal_logo_preview")} +
    + )} +
    + +
    + + + {t("edit_custom_game_modal_browse")} + + } + /> + + {heroPath && ( +
    + {t("edit_custom_game_modal_hero_preview")} +
    + )} +
    +
    + +
    + + +
    +
    +
    + ); +} \ No newline at end of file diff --git a/src/renderer/src/pages/game-details/modals/index.ts b/src/renderer/src/pages/game-details/modals/index.ts index e02421b9..7cc779ef 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-custom-game-modal"; 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 d749e777..dd9ba1dd 100644 --- a/src/types/level.types.ts +++ b/src/types/level.types.ts @@ -33,6 +33,8 @@ export interface User { export interface Game { title: string; iconUrl: string | null; + libraryHeroImageUrl: string | null; + logoImageUrl: string | null; playTimeInMilliseconds: number; unsyncedDeltaPlayTimeInMilliseconds?: number; lastTimePlayed: Date | null; From 3409b53268421c8b97fc3360d0e810fba81d5d65 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Fri, 19 Sep 2025 16:22:12 +0300 Subject: [PATCH 02/22] Feat: Custom Games --- .../library/add-custom-game-to-library.ts | 17 ++--- .../events/library/add-game-to-library.ts | 2 + .../events/library/change-game-playtime.ts | 4 +- src/main/events/library/get-library.ts | 8 ++- src/main/events/library/update-custom-game.ts | 10 +-- .../events/torrenting/start-game-download.ts | 2 + src/main/index.ts | 63 ++++++++++++------ .../library-sync/upload-games-batch.ts | 3 +- src/preload/index.ts | 21 ++++-- .../sidebar-adding-custom-game-modal.scss | 2 +- .../sidebar-adding-custom-game-modal.tsx | 47 ++++++------- .../src/components/sidebar/sidebar.tsx | 10 ++- src/renderer/src/helpers.ts | 8 +-- .../game-details/game-details-content.tsx | 2 +- .../src/pages/game-details/game-details.scss | 2 +- .../modals/edit-custom-game-modal.scss | 2 +- .../modals/edit-custom-game-modal.tsx | 66 ++++++++++--------- 17 files changed, 158 insertions(+), 111 deletions(-) diff --git a/src/main/events/library/add-custom-game-to-library.ts b/src/main/events/library/add-custom-game-to-library.ts index 382328a5..47fd3436 100644 --- a/src/main/events/library/add-custom-game-to-library.ts +++ b/src/main/events/library/add-custom-game-to-library.ts @@ -1,13 +1,8 @@ import { registerEvent } from "../register-event"; -import { - gamesSublevel, - gamesShopAssetsSublevel, - levelKeys, -} from "@main/level"; +import { gamesSublevel, gamesShopAssetsSublevel, levelKeys } from "@main/level"; import { randomUUID } from "crypto"; import type { GameShop } from "@types"; - const addCustomGameToLibrary = async ( _event: Electron.IpcMainInvokeEvent, title: string, @@ -21,12 +16,14 @@ const addCustomGameToLibrary = async ( const gameKey = levelKeys.game(shop, objectId); const existingGames = await gamesSublevel.iterator().all(); - const existingGame = existingGames.find(([_key, game]) => - game.executablePath === executablePath && !game.isDeleted + 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"); + throw new Error( + "A game with this executable path already exists in your library" + ); } const assets = { @@ -65,4 +62,4 @@ const addCustomGameToLibrary = async ( return game; }; -registerEvent("addCustomGameToLibrary", addCustomGameToLibrary); \ No newline at end of file +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..2fa6d20e 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, diff --git a/src/main/events/library/change-game-playtime.ts b/src/main/events/library/change-game-playtime.ts index 20da70ec..8ad252bd 100644 --- a/src/main/events/library/change-game-playtime.ts +++ b/src/main/events/library/change-game-playtime.ts @@ -13,13 +13,13 @@ 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, diff --git a/src/main/events/library/get-library.ts b/src/main/events/library/get-library.ts index ce859908..547045ae 100644 --- a/src/main/events/library/get-library.ts +++ b/src/main/events/library/get-library.ts @@ -18,11 +18,17 @@ const getLibrary = async (): Promise => { const download = await downloadsSublevel.get(key); const gameAssets = await gamesShopAssetsSublevel.get(key); + // 确保返回的对象符合 LibraryGame 类型 return { id: key, ...game, download: download ?? null, - ...gameAssets, + // 确保 gameAssets 中的可能为 null 的字段转换为 undefined + libraryHeroImageUrl: gameAssets?.libraryHeroImageUrl ?? undefined, + libraryImageUrl: gameAssets?.libraryImageUrl ?? undefined, + logoImageUrl: gameAssets?.logoImageUrl ?? undefined, + logoPosition: gameAssets?.logoPosition ?? undefined, + coverImageUrl: gameAssets?.coverImageUrl ?? undefined, }; }) ); diff --git a/src/main/events/library/update-custom-game.ts b/src/main/events/library/update-custom-game.ts index 4ff858a2..6152c0df 100644 --- a/src/main/events/library/update-custom-game.ts +++ b/src/main/events/library/update-custom-game.ts @@ -1,9 +1,5 @@ import { registerEvent } from "../register-event"; -import { - gamesSublevel, - gamesShopAssetsSublevel, - levelKeys, -} from "@main/level"; +import { gamesSublevel, gamesShopAssetsSublevel, levelKeys } from "@main/level"; import type { GameShop } from "@types"; const updateCustomGame = async ( @@ -16,7 +12,7 @@ const updateCustomGame = async ( libraryHeroImageUrl?: string ) => { const gameKey = levelKeys.game(shop, objectId); - + const existingGame = await gamesSublevel.get(gameKey); if (!existingGame) { throw new Error("Game not found"); @@ -50,4 +46,4 @@ const updateCustomGame = async ( return updatedGame; }; -registerEvent("updateCustomGame", updateCustomGame); \ No newline at end of file +registerEvent("updateCustomGame", updateCustomGame); 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 af9aa676..ab6980f9 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -65,36 +65,61 @@ app.whenReady().then(async () => { }); protocol.handle("gradient", (request) => { - const gradientCss = decodeURIComponent(request.url.slice("gradient:".length)); - - const match = gradientCss.match(/linear-gradient\(([^,]+),\s*([^,]+),\s*([^)]+)\)/); - + const gradientCss = decodeURIComponent( + request.url.slice("gradient:".length) + ); + + const match = gradientCss.match( + /linear-gradient\(([^,]+),\s*([^,]+),\s*([^)]+)\)/ + ); + let direction = "45deg"; - let color1 = '#4a90e2'; - let color2 = '#7b68ee'; - + let color1 = "#4a90e2"; + let color2 = "#7b68ee"; + if (match) { direction = match[1].trim(); color1 = match[2].trim(); color2 = match[3].trim(); } - - let x1 = "0%", y1 = "0%", x2 = "100%", y2 = "100%"; - + + let x1 = "0%", + y1 = "0%", + x2 = "100%", + y2 = "100%"; + if (direction === "to right") { - x1 = "0%"; y1 = "0%"; x2 = "100%"; y2 = "0%"; + x1 = "0%"; + y1 = "0%"; + x2 = "100%"; + y2 = "0%"; } else if (direction === "to bottom") { - x1 = "0%"; y1 = "0%"; x2 = "0%"; y2 = "100%"; + x1 = "0%"; + y1 = "0%"; + x2 = "0%"; + y2 = "100%"; } else if (direction === "45deg") { - x1 = "0%"; y1 = "100%"; x2 = "100%"; y2 = "0%"; + x1 = "0%"; + y1 = "100%"; + x2 = "100%"; + y2 = "0%"; } else if (direction === "135deg") { - x1 = "0%"; y1 = "0%"; x2 = "100%"; y2 = "100%"; + x1 = "0%"; + y1 = "0%"; + x2 = "100%"; + y2 = "100%"; } else if (direction === "225deg") { - x1 = "100%"; y1 = "0%"; x2 = "0%"; y2 = "100%"; + x1 = "100%"; + y1 = "0%"; + x2 = "0%"; + y2 = "100%"; } else if (direction === "315deg") { - x1 = "100%"; y1 = "100%"; x2 = "0%"; y2 = "0%"; + x1 = "100%"; + y1 = "100%"; + x2 = "0%"; + y2 = "0%"; } - + const svgContent = ` @@ -106,9 +131,9 @@ app.whenReady().then(async () => { `; - + return new Response(svgContent, { - headers: { 'Content-Type': 'image/svg+xml' } + headers: { "Content-Type": "image/svg+xml" }, }); }); diff --git a/src/main/services/library-sync/upload-games-batch.ts b/src/main/services/library-sync/upload-games-batch.ts index 10d2c8dd..653b5f40 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.shop !== "custom" + (game) => + !game.isDeleted && game.remoteId === null && game.shop !== "custom" ); }); diff --git a/src/preload/index.ts b/src/preload/index.ts index b9ad8d98..8d297051 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -135,7 +135,14 @@ contextBridge.exposeInMainWorld("electron", { logoImageUrl?: string, libraryHeroImageUrl?: string ) => - ipcRenderer.invoke("addCustomGameToLibrary", title, executablePath, iconUrl, logoImageUrl, libraryHeroImageUrl), + ipcRenderer.invoke( + "addCustomGameToLibrary", + title, + executablePath, + iconUrl, + logoImageUrl, + libraryHeroImageUrl + ), updateCustomGame: ( shop: GameShop, objectId: string, @@ -144,7 +151,15 @@ contextBridge.exposeInMainWorld("electron", { logoImageUrl?: string, libraryHeroImageUrl?: string ) => - ipcRenderer.invoke("updateCustomGame", shop, objectId, title, iconUrl, logoImageUrl, libraryHeroImageUrl), + ipcRenderer.invoke( + "updateCustomGame", + shop, + objectId, + title, + iconUrl, + logoImageUrl, + libraryHeroImageUrl + ), createGameShortcut: ( shop: GameShop, objectId: string, @@ -493,6 +508,4 @@ contextBridge.exposeInMainWorld("electron", { }, closeEditorWindow: (themeId?: string) => ipcRenderer.invoke("closeEditorWindow", themeId), - - }); 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 index 48b84da3..942384fe 100644 --- a/src/renderer/src/components/sidebar/sidebar-adding-custom-game-modal.scss +++ b/src/renderer/src/components/sidebar/sidebar-adding-custom-game-modal.scss @@ -49,4 +49,4 @@ justify-content: flex-end; gap: calc(globals.$spacing-unit * 2); } -} \ No newline at end of file +} 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 index 771c5464..4b18a211 100644 --- a/src/renderer/src/components/sidebar/sidebar-adding-custom-game-modal.tsx +++ b/src/renderer/src/components/sidebar/sidebar-adding-custom-game-modal.tsx @@ -5,7 +5,10 @@ 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 { + buildGameDetailsPath, + generateRandomGradient, +} from "@renderer/helpers"; import "./sidebar-adding-custom-game-modal.scss"; @@ -41,7 +44,7 @@ export function SidebarAddingCustomGameModal({ if (filePaths && filePaths.length > 0) { const selectedPath = filePaths[0]; setExecutablePath(selectedPath); - + if (!gameName.trim()) { const fileName = selectedPath.split(/[\\/]/).pop() || ""; const gameNameFromFile = fileName.replace(/\.[^/.]+$/, ""); @@ -54,8 +57,6 @@ export function SidebarAddingCustomGameModal({ setGameName(event.target.value); }; - - const handleAddGame = async () => { if (!gameName.trim() || !executablePath.trim()) { showErrorToast(t("custom_game_modal_fill_required")); @@ -70,7 +71,7 @@ export function SidebarAddingCustomGameModal({ 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, @@ -81,24 +82,22 @@ export function SidebarAddingCustomGameModal({ showSuccessToast(t("custom_game_modal_success")); updateLibrary(); - + const gameDetailsPath = buildGameDetailsPath({ shop: "custom", objectId: newGame.objectId, - title: newGame.title + 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") + error instanceof Error ? error.message : t("custom_game_modal_failed") ); } finally { setIsAdding(false); @@ -115,8 +114,6 @@ export function SidebarAddingCustomGameModal({ const isFormValid = gameName.trim() && executablePath.trim(); - - return ( - - - -
    - -
    ); -} \ No newline at end of file +} diff --git a/src/renderer/src/components/sidebar/sidebar.tsx b/src/renderer/src/components/sidebar/sidebar.tsx index a7d946a2..f77066a2 100644 --- a/src/renderer/src/components/sidebar/sidebar.tsx +++ b/src/renderer/src/components/sidebar/sidebar.tsx @@ -22,7 +22,11 @@ import { buildGameDetailsPath } from "@renderer/helpers"; import { SidebarProfile } from "./sidebar-profile"; import { sortBy } from "lodash-es"; import cn from "classnames"; -import { CommentDiscussionIcon, PlayIcon, PlusIcon } 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"; @@ -265,7 +269,9 @@ export function Sidebar() { {t("my_library")} -
    +
    } /> - + {iconPath && (
    } /> - + {logoPath && (
    } /> - + {heroPath && (
    - -
    ); -} \ No newline at end of file +} From f4e84e46ccb39a77d98d1cbe378193776e7b0298 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Fri, 19 Sep 2025 16:58:58 +0300 Subject: [PATCH 03/22] feat: custom game support/game info changing --- .../events/catalogue/save-game-shop-assets.ts | 10 +- src/main/events/index.ts | 1 + .../events/library/add-game-to-library.ts | 12 +- src/main/events/library/get-library.ts | 12 +- .../library/update-game-custom-assets.ts | 45 +++ .../library-sync/merge-with-remote-games.ts | 2 +- src/preload/index.ts | 17 + .../components/sidebar/sidebar-game-item.tsx | 2 +- .../game-details/game-details.context.tsx | 4 +- src/renderer/src/declaration.d.ts | 8 + .../game-details/game-details-content.tsx | 57 ++- .../game-details/modals/edit-game-modal.scss | 47 +++ .../game-details/modals/edit-game-modal.tsx | 367 ++++++++++++++++++ .../src/pages/game-details/modals/index.ts | 1 + src/types/level.types.ts | 3 + 15 files changed, 556 insertions(+), 32 deletions(-) create mode 100644 src/main/events/library/update-game-custom-assets.ts create mode 100644 src/renderer/src/pages/game-details/modals/edit-game-modal.scss create mode 100644 src/renderer/src/pages/game-details/modals/edit-game-modal.tsx diff --git a/src/main/events/catalogue/save-game-shop-assets.ts b/src/main/events/catalogue/save-game-shop-assets.ts index f0ec4343..46aab36a 100644 --- a/src/main/events/catalogue/save-game-shop-assets.ts +++ b/src/main/events/catalogue/save-game-shop-assets.ts @@ -10,7 +10,15 @@ 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 ea7597ad..a1acd596 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -16,6 +16,7 @@ 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/create-game-shortcut"; diff --git a/src/main/events/library/add-game-to-library.ts b/src/main/events/library/add-game-to-library.ts index 2fa6d20e..4fdeae30 100644 --- a/src/main/events/library/add-game-to-library.ts +++ b/src/main/events/library/add-game-to-library.ts @@ -43,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/get-library.ts b/src/main/events/library/get-library.ts index 547045ae..ea2971b9 100644 --- a/src/main/events/library/get-library.ts +++ b/src/main/events/library/get-library.ts @@ -18,18 +18,14 @@ const getLibrary = async (): Promise => { const download = await downloadsSublevel.get(key); const gameAssets = await gamesShopAssetsSublevel.get(key); - // 确保返回的对象符合 LibraryGame 类型 return { id: key, ...game, download: download ?? null, - // 确保 gameAssets 中的可能为 null 的字段转换为 undefined - libraryHeroImageUrl: gameAssets?.libraryHeroImageUrl ?? undefined, - libraryImageUrl: gameAssets?.libraryImageUrl ?? undefined, - logoImageUrl: gameAssets?.logoImageUrl ?? undefined, - logoPosition: gameAssets?.logoPosition ?? undefined, - coverImageUrl: gameAssets?.coverImageUrl ?? undefined, - }; + ...gameAssets, + // Ensure compatibility with LibraryGame type + libraryHeroImageUrl: game.libraryHeroImageUrl ?? gameAssets?.libraryHeroImageUrl, + } as LibraryGame; }) ); }); 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..99081a49 --- /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: customIconUrl !== undefined ? customIconUrl : existingGame.customIconUrl, + customLogoImageUrl: customLogoImageUrl !== undefined ? customLogoImageUrl : existingGame.customLogoImageUrl, + customHeroImageUrl: customHeroImageUrl !== undefined ? customHeroImageUrl : existingGame.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); \ No newline at end of file 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 c5bbe144..3e1398f0 100644 --- a/src/main/services/library-sync/merge-with-remote-games.ts +++ b/src/main/services/library-sync/merge-with-remote-games.ts @@ -57,7 +57,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/preload/index.ts b/src/preload/index.ts index 8d297051..ca918eee 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -160,6 +160,23 @@ contextBridge.exposeInMainWorld("electron", { 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/components/sidebar/sidebar-game-item.tsx b/src/renderer/src/components/sidebar/sidebar-game-item.tsx index 4f905c59..108a5536 100644 --- a/src/renderer/src/components/sidebar/sidebar-game-item.tsx +++ b/src/renderer/src/components/sidebar/sidebar-game-item.tsx @@ -20,7 +20,7 @@ export function SidebarGameItem({ const isCustomGame = game.shop === "custom"; const sidebarIcon = isCustomGame ? game.libraryImageUrl || game.iconUrl - : game.iconUrl; + : game.customIconUrl || game.iconUrl; return (
  • { - if (game?.title && game.shop === "custom") { + if (game?.title) { dispatch(setHeaderTitle(game.title)); } - }, [game?.title, game?.shop, dispatch]); + }, [game?.title, dispatch]); useEffect(() => { const unsubscribe = window.electron.onGamesRunning((gamesIds) => { diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index f66bd53e..5af0d04f 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -126,6 +126,14 @@ declare global { logoImageUrl?: string, libraryHeroImageUrl?: string ) => Promise; + updateGameCustomAssets: ( + shop: GameShop, + objectId: string, + title: string, + customIconUrl?: string | null, + customLogoImageUrl?: string | null, + customHeroImageUrl?: string | null + ) => Promise; createGameShortcut: ( shop: GameShop, objectId: string, 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 80c95fc4..cf300ceb 100644 --- a/src/renderer/src/pages/game-details/game-details-content.tsx +++ b/src/renderer/src/pages/game-details/game-details-content.tsx @@ -7,7 +7,7 @@ import { HeroPanel } from "./hero"; import { DescriptionHeader } from "./description-header/description-header"; import { GallerySlider } from "./gallery-slider/gallery-slider"; import { Sidebar } from "./sidebar/sidebar"; -import { EditCustomGameModal } from "./modals"; +import { EditCustomGameModal, EditGameModal } from "./modals"; import { useTranslation } from "react-i18next"; import { cloudSyncContext, gameDetailsContext } from "@renderer/context"; @@ -66,6 +66,7 @@ export function GameDetailsContent() { const [backdropOpacity, setBackdropOpacity] = useState(1); const [showEditCustomGameModal, setShowEditCustomGameModal] = useState(false); + const [showEditGameModal, setShowEditGameModal] = useState(false); const handleHeroLoad = async () => { const output = await average( @@ -105,6 +106,10 @@ export function GameDetailsContent() { setShowEditCustomGameModal(true); }; + const handleEditGameClick = () => { + setShowEditGameModal(true); + }; + const handleGameUpdated = (_updatedGame: any) => { updateGame(); updateLibrary(); @@ -115,12 +120,29 @@ export function GameDetailsContent() { }, [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 || "" - : shopDetails?.assets?.libraryHeroImageUrl || ""; + : getImageWithCustomPriority( + game?.customHeroImageUrl, + shopDetails?.assets?.libraryHeroImageUrl + ); + const logoImage = isCustomGame - ? game?.logoImageUrl || "" // Don't use icon as fallback for custom games - : shopDetails?.assets?.logoImageUrl || ""; + ? game?.logoImageUrl || "" + : getImageWithCustomPriority( + game?.customLogoImageUrl, + shopDetails?.assets?.logoImageUrl + ); return (
    - {game?.shop === "custom" && ( - - )} + {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..f37ea44b --- /dev/null +++ b/src/renderer/src/pages/game-details/modals/edit-game-modal.scss @@ -0,0 +1,47 @@ +.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__image-section { + display: flex; + flex-direction: column; + gap: 8px; +} + +.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); +} + +.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; +} \ No newline at end of file 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..ff8e7c3c --- /dev/null +++ b/src/renderer/src/pages/game-details/modals/edit-game-modal.tsx @@ -0,0 +1,367 @@ +import { useState, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { ImageIcon } from "@primer/octicons-react"; + +import { Modal, TextField, Button } from "@renderer/components"; +import { useToast } from "@renderer/hooks"; +import type { LibraryGame } from "@types"; + +import "./edit-game-modal.scss"; + +export interface EditGameModalProps { + visible: boolean; + onClose: () => void; + game: LibraryGame | null; + onGameUpdated: (updatedGame: any) => void; +} + +export function EditGameModal({ + visible, + onClose, + game, + onGameUpdated, +}: 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); + + useEffect(() => { + if (game && visible) { + setGameName(game.title || ""); + + // For custom games, use existing logic + if (game.shop === "custom") { + const currentIconPath = game.iconUrl?.startsWith("local:") + ? game.iconUrl.replace("local:", "") + : ""; + const currentLogoPath = game.logoImageUrl?.startsWith("local:") + ? game.logoImageUrl.replace("local:", "") + : ""; + const currentHeroPath = game.libraryHeroImageUrl?.startsWith("local:") + ? game.libraryHeroImageUrl.replace("local:", "") + : ""; + + setIconPath(currentIconPath); + setLogoPath(currentLogoPath); + setHeroPath(currentHeroPath); + } else { + // For non-custom games, use custom asset paths if they exist + const currentIconPath = game.customIconUrl?.startsWith("local:") + ? game.customIconUrl.replace("local:", "") + : ""; + const currentLogoPath = game.customLogoImageUrl?.startsWith("local:") + ? game.customLogoImageUrl.replace("local:", "") + : ""; + const currentHeroPath = game.customHeroImageUrl?.startsWith("local:") + ? game.customHeroImageUrl.replace("local:", "") + : ""; + + setIconPath(currentIconPath); + setLogoPath(currentLogoPath); + setHeroPath(currentHeroPath); + } + } + }, [game, visible]); + + const handleGameNameChange = (event: React.ChangeEvent) => { + setGameName(event.target.value); + }; + + const handleSelectIcon = async () => { + const { filePaths } = await window.electron.showOpenDialog({ + properties: ["openFile"], + filters: [ + { + name: t("edit_custom_game_modal_image_filter"), + extensions: ["jpg", "jpeg", "png", "gif", "webp"], + }, + ], + }); + + if (filePaths && filePaths.length > 0) { + setIconPath(filePaths[0]); + } + }; + + const handleSelectLogo = async () => { + const { filePaths } = await window.electron.showOpenDialog({ + properties: ["openFile"], + filters: [ + { + name: t("edit_custom_game_modal_image_filter"), + extensions: ["jpg", "jpeg", "png", "gif", "webp"], + }, + ], + }); + + if (filePaths && filePaths.length > 0) { + setLogoPath(filePaths[0]); + } + }; + + const handleSelectHero = async () => { + const { filePaths } = await window.electron.showOpenDialog({ + properties: ["openFile"], + filters: [ + { + name: t("edit_custom_game_modal_image_filter"), + extensions: ["jpg", "jpeg", "png", "gif", "webp"], + }, + ], + }); + + if (filePaths && filePaths.length > 0) { + setHeroPath(filePaths[0]); + } + }; + + const handleUpdateGame = async () => { + if (!game || !gameName.trim()) { + showErrorToast(t("edit_custom_game_modal_fill_required")); + return; + } + + setIsUpdating(true); + + try { + let updatedGame; + + if (game.shop === "custom") { + // For custom games, use existing logic + const iconUrl = iconPath ? `local:${iconPath}` : game.iconUrl; + const logoImageUrl = logoPath ? `local:${logoPath}` : game.logoImageUrl; + const libraryHeroImageUrl = heroPath + ? `local:${heroPath}` + : game.libraryHeroImageUrl; + + updatedGame = await window.electron.updateCustomGame( + game.shop, + game.objectId, + gameName.trim(), + iconUrl || undefined, + logoImageUrl || undefined, + libraryHeroImageUrl || undefined + ); + } else { + // For non-custom games, update custom assets + const customIconUrl = iconPath ? `local:${iconPath}` : null; + const customLogoImageUrl = logoPath ? `local:${logoPath}` : null; + const customHeroImageUrl = heroPath ? `local:${heroPath}` : null; + + updatedGame = await window.electron.updateGameCustomAssets( + game.shop, + game.objectId, + gameName.trim(), + customIconUrl, + customLogoImageUrl, + customHeroImageUrl + ); + } + + showSuccessToast(t("edit_custom_game_modal_success")); + onGameUpdated(updatedGame); + onClose(); + } catch (error) { + console.error("Failed to update game:", error); + showErrorToast( + error instanceof Error + ? error.message + : t("edit_custom_game_modal_failed") + ); + } finally { + setIsUpdating(false); + } + }; + + const handleClose = () => { + if (!isUpdating && game) { + setGameName(game.title || ""); + + if (game.shop === "custom") { + const currentIconPath = game.iconUrl?.startsWith("local:") + ? game.iconUrl.replace("local:", "") + : ""; + const currentLogoPath = game.logoImageUrl?.startsWith("local:") + ? game.logoImageUrl.replace("local:", "") + : ""; + const currentHeroPath = game.libraryHeroImageUrl?.startsWith("local:") + ? game.libraryHeroImageUrl.replace("local:", "") + : ""; + + setIconPath(currentIconPath); + setLogoPath(currentLogoPath); + setHeroPath(currentHeroPath); + } else { + const currentIconPath = game.customIconUrl?.startsWith("local:") + ? game.customIconUrl.replace("local:", "") + : ""; + const currentLogoPath = game.customLogoImageUrl?.startsWith("local:") + ? game.customLogoImageUrl.replace("local:", "") + : ""; + const currentHeroPath = game.customHeroImageUrl?.startsWith("local:") + ? game.customHeroImageUrl.replace("local:", "") + : ""; + + setIconPath(currentIconPath); + setLogoPath(currentLogoPath); + setHeroPath(currentHeroPath); + } + onClose(); + } + }; + + const isFormValid = gameName.trim(); + + const getIconPreviewUrl = () => { + return iconPath ? `local:${iconPath}` : null; + }; + + const getLogoPreviewUrl = () => { + return logoPath ? `local:${logoPath}` : null; + }; + + const getHeroPreviewUrl = () => { + return heroPath ? `local:${heroPath}` : null; + }; + + return ( + +
    +
    + + +
    + + + {t("edit_custom_game_modal_browse")} + + } + /> + + {iconPath && ( +
    + {t("edit_custom_game_modal_icon_preview")} +
    + )} +
    + +
    + + + {t("edit_custom_game_modal_browse")} + + } + /> + + {logoPath && ( +
    + {t("edit_custom_game_modal_logo_preview")} +
    + )} +
    + +
    + + + {t("edit_custom_game_modal_browse")} + + } + /> + + {heroPath && ( +
    + {t("edit_custom_game_modal_hero_preview")} +
    + )} +
    +
    + +
    + + +
    +
    +
    + ); +} \ No newline at end of file diff --git a/src/renderer/src/pages/game-details/modals/index.ts b/src/renderer/src/pages/game-details/modals/index.ts index 7cc779ef..2e09806d 100644 --- a/src/renderer/src/pages/game-details/modals/index.ts +++ b/src/renderer/src/pages/game-details/modals/index.ts @@ -2,3 +2,4 @@ export * from "./repacks-modal"; export * from "./download-settings-modal"; export * from "./game-options-modal"; export * from "./edit-custom-game-modal"; +export * from "./edit-game-modal"; diff --git a/src/types/level.types.ts b/src/types/level.types.ts index dd9ba1dd..bf5429c5 100644 --- a/src/types/level.types.ts +++ b/src/types/level.types.ts @@ -35,6 +35,9 @@ export interface Game { 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; From 672ddff9f8b563e73d534265034e0dbf0814b090 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Fri, 19 Sep 2025 17:15:59 +0300 Subject: [PATCH 04/22] security fix --- .../events/catalogue/save-game-shop-assets.ts | 13 +++++++------ src/main/events/library/get-library.ts | 3 ++- .../events/library/update-game-custom-assets.ts | 15 +++++++++++---- src/main/index.ts | 3 ++- .../pages/game-details/game-details-content.tsx | 10 +++++++--- .../game-details/modals/edit-game-modal.scss | 2 +- .../pages/game-details/modals/edit-game-modal.tsx | 2 +- 7 files changed, 31 insertions(+), 17 deletions(-) diff --git a/src/main/events/catalogue/save-game-shop-assets.ts b/src/main/events/catalogue/save-game-shop-assets.ts index 46aab36a..bf5f8b81 100644 --- a/src/main/events/catalogue/save-game-shop-assets.ts +++ b/src/main/events/catalogue/save-game-shop-assets.ts @@ -10,14 +10,15 @@ const saveGameShopAssets = async ( ): Promise => { const key = levelKeys.game(shop, objectId); const existingAssets = await gamesShopAssetsSublevel.get(key); - + // 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, + const shouldPreserveTitle = + existingAssets?.title && existingAssets.title !== assets.title; + + return gamesShopAssetsSublevel.put(key, { + ...existingAssets, ...assets, - title: shouldPreserveTitle ? existingAssets.title : assets.title + title: shouldPreserveTitle ? existingAssets.title : assets.title, }); }; diff --git a/src/main/events/library/get-library.ts b/src/main/events/library/get-library.ts index ea2971b9..6314f83d 100644 --- a/src/main/events/library/get-library.ts +++ b/src/main/events/library/get-library.ts @@ -24,7 +24,8 @@ const getLibrary = async (): Promise => { download: download ?? null, ...gameAssets, // Ensure compatibility with LibraryGame type - libraryHeroImageUrl: game.libraryHeroImageUrl ?? gameAssets?.libraryHeroImageUrl, + libraryHeroImageUrl: + game.libraryHeroImageUrl ?? gameAssets?.libraryHeroImageUrl, } as LibraryGame; }) ); diff --git a/src/main/events/library/update-game-custom-assets.ts b/src/main/events/library/update-game-custom-assets.ts index 99081a49..6049ef5d 100644 --- a/src/main/events/library/update-game-custom-assets.ts +++ b/src/main/events/library/update-game-custom-assets.ts @@ -21,9 +21,16 @@ const updateGameCustomAssets = async ( const updatedGame = { ...existingGame, title, - customIconUrl: customIconUrl !== undefined ? customIconUrl : existingGame.customIconUrl, - customLogoImageUrl: customLogoImageUrl !== undefined ? customLogoImageUrl : existingGame.customLogoImageUrl, - customHeroImageUrl: customHeroImageUrl !== undefined ? customHeroImageUrl : existingGame.customHeroImageUrl, + customIconUrl: + customIconUrl !== undefined ? customIconUrl : existingGame.customIconUrl, + customLogoImageUrl: + customLogoImageUrl !== undefined + ? customLogoImageUrl + : existingGame.customLogoImageUrl, + customHeroImageUrl: + customHeroImageUrl !== undefined + ? customHeroImageUrl + : existingGame.customHeroImageUrl, }; await gamesSublevel.put(gameKey, updatedGame); @@ -42,4 +49,4 @@ const updateGameCustomAssets = async ( return updatedGame; }; -registerEvent("updateGameCustomAssets", updateGameCustomAssets); \ No newline at end of file +registerEvent("updateGameCustomAssets", updateGameCustomAssets); diff --git a/src/main/index.ts b/src/main/index.ts index ab6980f9..9c390176 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -69,8 +69,9 @@ app.whenReady().then(async () => { request.url.slice("gradient:".length) ); + // Fixed regex to prevent ReDoS - removed nested quantifiers and backtracking const match = gradientCss.match( - /linear-gradient\(([^,]+),\s*([^,]+),\s*([^)]+)\)/ + /^linear-gradient\(([^,()]+),\s*([^,()]+),\s*([^,()]+)\)$/ ); let direction = "45deg"; 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 cf300ceb..b8681c5c 100644 --- a/src/renderer/src/pages/game-details/game-details-content.tsx +++ b/src/renderer/src/pages/game-details/game-details-content.tsx @@ -120,7 +120,7 @@ export function GameDetailsContent() { }, [getGameArtifacts]); const isCustomGame = game?.shop === "custom"; - + // Helper function to get image with custom asset priority const getImageWithCustomPriority = ( customUrl: string | null | undefined, @@ -136,7 +136,7 @@ export function GameDetailsContent() { game?.customHeroImageUrl, shopDetails?.assets?.libraryHeroImageUrl ); - + const logoImage = isCustomGame ? game?.logoImageUrl || "" : getImageWithCustomPriority( @@ -181,7 +181,11 @@ export function GameDetailsContent() { - } - /> - - {iconPath && ( -
    - {t("edit_custom_game_modal_icon_preview")} -
    - )} - - -
    - - - {t("edit_custom_game_modal_browse")} - - } - /> - - {logoPath && ( -
    - {t("edit_custom_game_modal_logo_preview")} -
    - )} -
    - -
    - - - {t("edit_custom_game_modal_browse")} - - } - /> - - {heroPath && ( -
    - {t("edit_custom_game_modal_hero_preview")} -
    - )} -
    - - -
    - - -
    - - - ); -} 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 index 185da424..1731e03c 100644 --- a/src/renderer/src/pages/game-details/modals/edit-game-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/edit-game-modal.tsx @@ -4,14 +4,14 @@ import { ImageIcon } from "@primer/octicons-react"; import { Modal, TextField, Button } from "@renderer/components"; import { useToast } from "@renderer/hooks"; -import type { LibraryGame } from "@types"; +import type { LibraryGame, Game } from "@types"; import "./edit-game-modal.scss"; export interface EditGameModalProps { visible: boolean; onClose: () => void; - game: LibraryGame | null; + game: LibraryGame | Game | null; onGameUpdated: (updatedGame: any) => void; } @@ -20,7 +20,7 @@ export function EditGameModal({ onClose, game, onGameUpdated, -}: EditGameModalProps) { +}: Readonly) { const { t } = useTranslation("sidebar"); const { showSuccessToast, showErrorToast } = useToast(); @@ -30,13 +30,18 @@ export function EditGameModal({ const [heroPath, setHeroPath] = useState(""); const [isUpdating, setIsUpdating] = useState(false); + // Helper function to check if game is a custom game + const isCustomGame = (game: LibraryGame | Game): boolean => { + return game.shop === "custom"; + }; + // Helper function to extract local path from URL const extractLocalPath = (url: string | null | undefined): string => { return url?.startsWith("local:") ? url.replace("local:", "") : ""; }; // Helper function to set asset paths for custom games - const setCustomGameAssets = (game: LibraryGame) => { + const setCustomGameAssets = (game: LibraryGame | Game) => { setIconPath(extractLocalPath(game.iconUrl)); setLogoPath(extractLocalPath(game.logoImageUrl)); setHeroPath(extractLocalPath(game.libraryHeroImageUrl)); @@ -53,10 +58,10 @@ export function EditGameModal({ if (game && visible) { setGameName(game.title || ""); - if (game.shop === "custom") { + if (isCustomGame(game)) { setCustomGameAssets(game); } else { - setNonCustomGameAssets(game); + setNonCustomGameAssets(game as LibraryGame); } } }, [game, visible]); @@ -114,7 +119,7 @@ export function EditGameModal({ }; // Helper function to prepare custom game assets - const prepareCustomGameAssets = (game: LibraryGame) => { + const prepareCustomGameAssets = (game: LibraryGame | Game) => { const iconUrl = iconPath ? `local:${iconPath}` : game.iconUrl; const logoImageUrl = logoPath ? `local:${logoPath}` : game.logoImageUrl; const libraryHeroImageUrl = heroPath @@ -134,7 +139,7 @@ export function EditGameModal({ }; // Helper function to update custom game - const updateCustomGame = async (game: LibraryGame) => { + const updateCustomGame = async (game: LibraryGame | Game) => { const { iconUrl, logoImageUrl, libraryHeroImageUrl } = prepareCustomGameAssets(game); @@ -173,9 +178,9 @@ export function EditGameModal({ try { const updatedGame = - game.shop === "custom" + isCustomGame(game) ? await updateCustomGame(game) - : await updateNonCustomGame(game); + : await updateNonCustomGame(game as LibraryGame); showSuccessToast(t("edit_custom_game_modal_success")); onGameUpdated(updatedGame); @@ -193,13 +198,13 @@ export function EditGameModal({ }; // Helper function to reset form to initial state - const resetFormToInitialState = (game: LibraryGame) => { + const resetFormToInitialState = (game: LibraryGame | Game) => { setGameName(game.title || ""); - if (game.shop === "custom") { + if (isCustomGame(game)) { setCustomGameAssets(game); } else { - setNonCustomGameAssets(game); + setNonCustomGameAssets(game as LibraryGame); } }; @@ -212,16 +217,16 @@ export function EditGameModal({ const isFormValid = gameName.trim(); - const getIconPreviewUrl = () => { - return iconPath ? `local:${iconPath}` : null; + const getIconPreviewUrl = (): string | undefined => { + return iconPath ? `local:${iconPath}` : undefined; }; - const getLogoPreviewUrl = () => { - return logoPath ? `local:${logoPath}` : null; + const getLogoPreviewUrl = (): string | undefined => { + return logoPath ? `local:${logoPath}` : undefined; }; - const getHeroPreviewUrl = () => { - return heroPath ? `local:${heroPath}` : null; + const getHeroPreviewUrl = (): string | undefined => { + return heroPath ? `local:${heroPath}` : undefined; }; return ( @@ -265,7 +270,7 @@ export function EditGameModal({ {iconPath && (
    {t("edit_custom_game_modal_icon_preview")} @@ -296,7 +301,7 @@ export function EditGameModal({ {logoPath && (
    {t("edit_custom_game_modal_logo_preview")} @@ -327,7 +332,7 @@ export function EditGameModal({ {heroPath && (
    {t("edit_custom_game_modal_hero_preview")} diff --git a/src/renderer/src/pages/game-details/modals/index.ts b/src/renderer/src/pages/game-details/modals/index.ts index 2e09806d..724e0003 100644 --- a/src/renderer/src/pages/game-details/modals/index.ts +++ b/src/renderer/src/pages/game-details/modals/index.ts @@ -1,5 +1,4 @@ export * from "./repacks-modal"; export * from "./download-settings-modal"; export * from "./game-options-modal"; -export * from "./edit-custom-game-modal"; export * from "./edit-game-modal"; From 9ce1c40b2192f9a6203b51b1432ee6004c52fddb Mon Sep 17 00:00:00 2001 From: Moyasee Date: Fri, 19 Sep 2025 19:48:05 +0300 Subject: [PATCH 10/22] redirect to home page when removing custom game from library --- src/renderer/src/pages/game-details/game-details.tsx | 1 + .../src/pages/game-details/modals/edit-game-modal.tsx | 7 +++---- .../src/pages/game-details/modals/game-options-modal.tsx | 7 +++++++ 3 files changed, 11 insertions(+), 4 deletions(-) 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/modals/edit-game-modal.tsx b/src/renderer/src/pages/game-details/modals/edit-game-modal.tsx index 1731e03c..e33d59b2 100644 --- a/src/renderer/src/pages/game-details/modals/edit-game-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/edit-game-modal.tsx @@ -177,10 +177,9 @@ export function EditGameModal({ setIsUpdating(true); try { - const updatedGame = - isCustomGame(game) - ? await updateCustomGame(game) - : await updateNonCustomGame(game as LibraryGame); + const updatedGame = isCustomGame(game) + ? await updateCustomGame(game) + : await updateNonCustomGame(game as LibraryGame); showSuccessToast(t("edit_custom_game_modal_success")); onGameUpdated(updatedGame); 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..34f2690d 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 () => { From bf9e3de0b5f7c28564e169af316bdf6a863ddffb Mon Sep 17 00:00:00 2001 From: Moyasee Date: Fri, 19 Sep 2025 20:04:09 +0300 Subject: [PATCH 11/22] fixed background color in hero arent being properly used if game is custom --- .../src/pages/game-details/game-details-content.tsx | 11 ++++++++++- .../pages/game-details/modals/game-options-modal.tsx | 2 +- 2 files changed, 11 insertions(+), 2 deletions(-) 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 4597263b..9c2b5bd8 100644 --- a/src/renderer/src/pages/game-details/game-details-content.tsx +++ b/src/renderer/src/pages/game-details/game-details-content.tsx @@ -68,8 +68,17 @@ export function GameDetailsContent() { const [showEditGameModal, setShowEditGameModal] = useState(false); const handleHeroLoad = async () => { + // Use the same logic as heroImage to get the correct URL for both custom and non-custom games + const isCustomGame = game?.shop === "custom"; + const heroImageUrl = isCustomGame + ? game?.libraryHeroImageUrl || game?.iconUrl || "" + : getImageWithCustomPriority( + game?.customHeroImageUrl, + shopDetails?.assets?.libraryHeroImageUrl + ); + const output = await average( - shopDetails?.assets?.libraryHeroImageUrl ?? "", + heroImageUrl, { amount: 1, format: "hex", 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 34f2690d..69d610d9 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 @@ -92,7 +92,7 @@ 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(); From 9f4fd0ce61805e5f447c304ebf05c398874bb1e3 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Fri, 19 Sep 2025 20:46:53 +0300 Subject: [PATCH 12/22] added proper image saving for custom games + edited game settings to hide buttons if game is custom --- src/main/events/index.ts | 2 + .../events/library/cleanup-unused-assets.ts | 73 +++++++++ .../events/library/copy-custom-game-asset.ts | 42 ++++++ src/preload/index.ts | 5 + src/renderer/src/declaration.d.ts | 5 + .../game-details/game-details-content.tsx | 11 +- .../game-details/modals/edit-game-modal.tsx | 39 ++++- .../modals/game-options-modal.tsx | 140 ++++++++++-------- 8 files changed, 242 insertions(+), 75 deletions(-) create mode 100644 src/main/events/library/cleanup-unused-assets.ts create mode 100644 src/main/events/library/copy-custom-game-asset.ts diff --git a/src/main/events/index.ts b/src/main/events/index.ts index a1acd596..deab53a6 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -39,7 +39,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"; 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..9cde548d --- /dev/null +++ b/src/main/events/library/cleanup-unused-assets.ts @@ -0,0 +1,73 @@ +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); \ No newline at end of file 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..dfb7bf47 --- /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); \ No newline at end of file diff --git a/src/preload/index.ts b/src/preload/index.ts index ca918eee..3bcd4524 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -143,6 +143,11 @@ contextBridge.exposeInMainWorld("electron", { logoImageUrl, libraryHeroImageUrl ), + copyCustomGameAsset: ( + sourcePath: string, + assetType: "icon" | "logo" | "hero" + ) => ipcRenderer.invoke("copyCustomGameAsset", sourcePath, assetType), + cleanupUnusedAssets: () => ipcRenderer.invoke("cleanupUnusedAssets"), updateCustomGame: ( shop: GameShop, objectId: string, diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index 5af0d04f..0515f1f0 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -126,6 +126,11 @@ declare global { logoImageUrl?: string, libraryHeroImageUrl?: string ) => Promise; + copyCustomGameAsset: ( + sourcePath: string, + assetType: "icon" | "logo" | "hero" + ) => Promise; + cleanupUnusedAssets: () => Promise<{ deletedCount: number; errors: string[] }>; updateGameCustomAssets: ( shop: GameShop, objectId: string, 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 9c2b5bd8..22f56630 100644 --- a/src/renderer/src/pages/game-details/game-details-content.tsx +++ b/src/renderer/src/pages/game-details/game-details-content.tsx @@ -77,13 +77,10 @@ export function GameDetailsContent() { shopDetails?.assets?.libraryHeroImageUrl ); - const output = await average( - heroImageUrl, - { - amount: 1, - format: "hex", - } - ); + const output = await average(heroImageUrl, { + amount: 1, + format: "hex", + }); const backgroundColor = output ? new Color(output).darken(0.7).toString() 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 index e33d59b2..b922a231 100644 --- a/src/renderer/src/pages/game-details/modals/edit-game-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/edit-game-modal.tsx @@ -82,7 +82,18 @@ export function EditGameModal({ }); if (filePaths && filePaths.length > 0) { - setIconPath(filePaths[0]); + try { + // Copy the asset to the app's assets folder + const copiedAssetUrl = await window.electron.copyCustomGameAsset( + filePaths[0], + "icon" + ); + setIconPath(copiedAssetUrl.replace("local:", "")); + } catch (error) { + console.error("Failed to copy icon asset:", error); + // Fallback to original behavior + setIconPath(filePaths[0]); + } } }; @@ -98,7 +109,18 @@ export function EditGameModal({ }); if (filePaths && filePaths.length > 0) { - setLogoPath(filePaths[0]); + try { + // Copy the asset to the app's assets folder + const copiedAssetUrl = await window.electron.copyCustomGameAsset( + filePaths[0], + "logo" + ); + setLogoPath(copiedAssetUrl.replace("local:", "")); + } catch (error) { + console.error("Failed to copy logo asset:", error); + // Fallback to original behavior + setLogoPath(filePaths[0]); + } } }; @@ -114,7 +136,18 @@ export function EditGameModal({ }); if (filePaths && filePaths.length > 0) { - setHeroPath(filePaths[0]); + try { + // Copy the asset to the app's assets folder + const copiedAssetUrl = await window.electron.copyCustomGameAsset( + filePaths[0], + "hero" + ); + setHeroPath(copiedAssetUrl.replace("local:", "")); + } catch (error) { + console.error("Failed to copy hero asset:", error); + // Fallback to original behavior + setHeroPath(filePaths[0]); + } } }; 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 69d610d9..ccbce05d 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 @@ -353,14 +353,16 @@ export function GameOptionsModal({ > {t("create_shortcut")} - + {game.shop !== "custom" && ( + + )} {shouldShowCreateStartMenuShortcut && (
    - - {t("enable_automatic_cloud_sync")} - - Hydra Cloud - -
    - } - checked={automaticCloudSync} - disabled={!hasActiveSubscription || !game.executablePath} - onChange={handleToggleAutomaticCloudSync} - /> + {game.shop !== "custom" && ( + + {t("enable_automatic_cloud_sync")} + + Hydra Cloud + + + } + checked={automaticCloudSync} + disabled={!hasActiveSubscription || !game.executablePath} + onChange={handleToggleAutomaticCloudSync} + /> + )} {shouldShowWinePrefixConfiguration && (
    @@ -448,33 +452,35 @@ export function GameOptionsModal({ />
    -
    -
    -

    {t("downloads_section_title")}

    -

    - {t("downloads_section_description")} -

    -
    + {game.shop !== "custom" && ( +
    +
    +

    {t("downloads_section_title")}

    +

    + {t("downloads_section_description")} +

    +
    -
    - - {game.download?.downloadPath && ( +
    - )} + {game.download?.downloadPath && ( + + )} +
    -
    + )}
    @@ -493,18 +499,20 @@ export function GameOptionsModal({ {t("remove_from_library")} - + {game.shop !== "custom" && ( + + )} - + {game.shop !== "custom" && ( + + )}
    From de70beb01e138e0974abea2f9c861477e2674c2a Mon Sep 17 00:00:00 2001 From: Moyasee Date: Fri, 19 Sep 2025 21:06:15 +0300 Subject: [PATCH 13/22] Preload images for non-custom games. Added ability to restore images to default if game is non-custom --- .../events/library/cleanup-unused-assets.ts | 19 ++- .../events/library/copy-custom-game-asset.ts | 4 +- .../library/update-game-custom-assets.ts | 6 +- src/renderer/src/declaration.d.ts | 5 +- .../game-details/game-details-content.tsx | 1 + .../game-details/modals/edit-game-modal.tsx | 146 ++++++++++++++---- .../modals/game-options-modal.tsx | 4 +- 7 files changed, 137 insertions(+), 48 deletions(-) diff --git a/src/main/events/library/cleanup-unused-assets.ts b/src/main/events/library/cleanup-unused-assets.ts index 9cde548d..22490c07 100644 --- a/src/main/events/library/cleanup-unused-assets.ts +++ b/src/main/events/library/cleanup-unused-assets.ts @@ -9,27 +9,27 @@ const getCustomGamesAssetsPath = () => { 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)); + 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); + .filter((game) => game.shop === "custom" && !game.isDeleted); const usedPaths = new Set(); - customGames.forEach(game => { + customGames.forEach((game) => { // Extract file paths from local URLs if (game.iconUrl?.startsWith("local:")) { usedPaths.add(game.iconUrl.replace("local:", "")); @@ -45,11 +45,14 @@ const getUsedAssetPaths = async (): Promise> => { return usedPaths; }; -export const cleanupUnusedAssets = async (): Promise<{ deletedCount: number; errors: string[] }> => { +export const cleanupUnusedAssets = async (): Promise<{ + deletedCount: number; + errors: string[]; +}> => { try { const allAssets = await getAllCustomGameAssets(); const usedAssets = await getUsedAssetPaths(); - + const errors: string[] = []; let deletedCount = 0; @@ -70,4 +73,4 @@ export const cleanupUnusedAssets = async (): Promise<{ deletedCount: number; err } }; -ipcMain.handle("cleanupUnusedAssets", cleanupUnusedAssets); \ No newline at end of file +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 index dfb7bf47..07c3d6f7 100644 --- a/src/main/events/library/copy-custom-game-asset.ts +++ b/src/main/events/library/copy-custom-game-asset.ts @@ -26,7 +26,7 @@ const copyCustomGameAsset = async ( // Get file extension const fileExtension = path.extname(sourcePath); - + // Generate unique filename const uniqueId = randomUUID(); const fileName = `${assetType}-${uniqueId}${fileExtension}`; @@ -39,4 +39,4 @@ const copyCustomGameAsset = async ( return `local:${destinationPath}`; }; -registerEvent("copyCustomGameAsset", copyCustomGameAsset); \ No newline at end of file +registerEvent("copyCustomGameAsset", copyCustomGameAsset); diff --git a/src/main/events/library/update-game-custom-assets.ts b/src/main/events/library/update-game-custom-assets.ts index 6e504e92..74446fb0 100644 --- a/src/main/events/library/update-game-custom-assets.ts +++ b/src/main/events/library/update-game-custom-assets.ts @@ -21,9 +21,9 @@ const updateGameCustomAssets = async ( const updatedGame = { ...existingGame, title, - customIconUrl: customIconUrl ?? existingGame.customIconUrl, - customLogoImageUrl: customLogoImageUrl ?? existingGame.customLogoImageUrl, - customHeroImageUrl: customHeroImageUrl ?? existingGame.customHeroImageUrl, + customIconUrl: customIconUrl !== undefined ? customIconUrl : existingGame.customIconUrl, + customLogoImageUrl: customLogoImageUrl !== undefined ? customLogoImageUrl : existingGame.customLogoImageUrl, + customHeroImageUrl: customHeroImageUrl !== undefined ? customHeroImageUrl : existingGame.customHeroImageUrl, }; await gamesSublevel.put(gameKey, updatedGame); diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index 0515f1f0..45bdb20d 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -130,7 +130,10 @@ declare global { sourcePath: string, assetType: "icon" | "logo" | "hero" ) => Promise; - cleanupUnusedAssets: () => Promise<{ deletedCount: number; errors: string[] }>; + cleanupUnusedAssets: () => Promise<{ + deletedCount: number; + errors: string[]; + }>; updateGameCustomAssets: ( shop: GameShop, objectId: string, 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 22f56630..6f3c2ee2 100644 --- a/src/renderer/src/pages/game-details/game-details-content.tsx +++ b/src/renderer/src/pages/game-details/game-details-content.tsx @@ -232,6 +232,7 @@ export function GameDetailsContent() { visible={showEditGameModal} onClose={() => setShowEditGameModal(false)} game={game} + shopDetails={shopDetails} onGameUpdated={handleGameUpdated} /> 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 index b922a231..45653649 100644 --- a/src/renderer/src/pages/game-details/modals/edit-game-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/edit-game-modal.tsx @@ -1,10 +1,10 @@ import { useState, useEffect } from "react"; import { useTranslation } from "react-i18next"; -import { ImageIcon } from "@primer/octicons-react"; +import { ImageIcon, ReplyIcon } from "@primer/octicons-react"; import { Modal, TextField, Button } from "@renderer/components"; import { useToast } from "@renderer/hooks"; -import type { LibraryGame, Game } from "@types"; +import type { LibraryGame, Game, ShopDetailsWithAssets } from "@types"; import "./edit-game-modal.scss"; @@ -12,6 +12,7 @@ export interface EditGameModalProps { visible: boolean; onClose: () => void; game: LibraryGame | Game | null; + shopDetails?: ShopDetailsWithAssets | null; onGameUpdated: (updatedGame: any) => void; } @@ -19,6 +20,7 @@ export function EditGameModal({ visible, onClose, game, + shopDetails, onGameUpdated, }: Readonly) { const { t } = useTranslation("sidebar"); @@ -29,6 +31,11 @@ export function EditGameModal({ const [logoPath, setLogoPath] = useState(""); const [heroPath, setHeroPath] = useState(""); const [isUpdating, setIsUpdating] = useState(false); + + // Store default image URLs for non-custom games + const [defaultIconUrl, setDefaultIconUrl] = useState(null); + const [defaultLogoUrl, setDefaultLogoUrl] = useState(null); + const [defaultHeroUrl, setDefaultHeroUrl] = useState(null); // Helper function to check if game is a custom game const isCustomGame = (game: LibraryGame | Game): boolean => { @@ -52,6 +59,11 @@ export function EditGameModal({ setIconPath(extractLocalPath(game.customIconUrl)); setLogoPath(extractLocalPath(game.customLogoImageUrl)); setHeroPath(extractLocalPath(game.customHeroImageUrl)); + + // Store default URLs for restore functionality from shopDetails.assets + setDefaultIconUrl(shopDetails?.assets?.iconUrl || game.iconUrl || null); + setDefaultLogoUrl(shopDetails?.assets?.logoImageUrl || game.logoImageUrl || null); + setDefaultHeroUrl(shopDetails?.assets?.libraryHeroImageUrl || game.libraryHeroImageUrl || null); }; useEffect(() => { @@ -64,7 +76,7 @@ export function EditGameModal({ setNonCustomGameAssets(game as LibraryGame); } } - }, [game, visible]); + }, [game, visible, shopDetails]); const handleGameNameChange = (event: React.ChangeEvent) => { setGameName(event.target.value); @@ -151,6 +163,19 @@ export function EditGameModal({ } }; + // Helper functions to restore default images for non-custom games + const handleRestoreDefaultIcon = () => { + setIconPath(""); + }; + + const handleRestoreDefaultLogo = () => { + setLogoPath(""); + }; + + const handleRestoreDefaultHero = () => { + setHeroPath(""); + }; + // Helper function to prepare custom game assets const prepareCustomGameAssets = (game: LibraryGame | Game) => { const iconUrl = iconPath ? `local:${iconPath}` : game.iconUrl; @@ -235,6 +260,10 @@ export function EditGameModal({ if (isCustomGame(game)) { setCustomGameAssets(game); + // Clear default URLs for custom games + setDefaultIconUrl(null); + setDefaultLogoUrl(null); + setDefaultHeroUrl(null); } else { setNonCustomGameAssets(game as LibraryGame); } @@ -250,14 +279,26 @@ export function EditGameModal({ const isFormValid = gameName.trim(); const getIconPreviewUrl = (): string | undefined => { + if (!isCustomGame(game!)) { + // For non-custom games, show custom image if set, otherwise show default + return iconPath ? `local:${iconPath}` : defaultIconUrl || undefined; + } return iconPath ? `local:${iconPath}` : undefined; }; const getLogoPreviewUrl = (): string | undefined => { + if (!isCustomGame(game!)) { + // For non-custom games, show custom image if set, otherwise show default + return logoPath ? `local:${logoPath}` : defaultLogoUrl || undefined; + } return logoPath ? `local:${logoPath}` : undefined; }; const getHeroPreviewUrl = (): string | undefined => { + if (!isCustomGame(game!)) { + // For non-custom games, show custom image if set, otherwise show default + return heroPath ? `local:${heroPath}` : defaultHeroUrl || undefined; + } return heroPath ? `local:${heroPath}` : undefined; }; @@ -287,19 +328,32 @@ export function EditGameModal({ readOnly theme="dark" rightContent={ - +
    + + {!isCustomGame(game!) && iconPath && ( + + )} +
    } /> - {iconPath && ( + {(iconPath || (!isCustomGame(game!) && defaultIconUrl)) && (
    - - {t("edit_custom_game_modal_browse")} - +
    + + {!isCustomGame(game!) && logoPath && ( + + )} +
    } /> - {logoPath && ( + {(logoPath || (!isCustomGame(game!) && defaultLogoUrl)) && (
    - - {t("edit_custom_game_modal_browse")} - +
    + + {!isCustomGame(game!) && heroPath && ( + + )} +
    } /> - {heroPath && ( + {(heroPath || (!isCustomGame(game!) && defaultHeroUrl)) && (
    {t("remove_files")} From 607bc6407c9e2bb054de3cedd6fe588a05554797 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Fri, 19 Sep 2025 21:16:46 +0300 Subject: [PATCH 14/22] fixed assertions + use ?? operators --- .../library/update-game-custom-assets.ts | 6 +-- .../game-details/modals/edit-game-modal.tsx | 40 +++++++++++-------- 2 files changed, 26 insertions(+), 20 deletions(-) diff --git a/src/main/events/library/update-game-custom-assets.ts b/src/main/events/library/update-game-custom-assets.ts index 74446fb0..866cd60e 100644 --- a/src/main/events/library/update-game-custom-assets.ts +++ b/src/main/events/library/update-game-custom-assets.ts @@ -21,9 +21,9 @@ const updateGameCustomAssets = async ( const updatedGame = { ...existingGame, title, - customIconUrl: customIconUrl !== undefined ? customIconUrl : existingGame.customIconUrl, - customLogoImageUrl: customLogoImageUrl !== undefined ? customLogoImageUrl : existingGame.customLogoImageUrl, - customHeroImageUrl: customHeroImageUrl !== undefined ? customHeroImageUrl : existingGame.customHeroImageUrl, + ...(customIconUrl !== undefined && { customIconUrl }), + ...(customLogoImageUrl !== undefined && { customLogoImageUrl }), + ...(customHeroImageUrl !== undefined && { customHeroImageUrl }), }; await gamesSublevel.put(gameKey, updatedGame); 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 index 45653649..ea34223c 100644 --- a/src/renderer/src/pages/game-details/modals/edit-game-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/edit-game-modal.tsx @@ -31,7 +31,7 @@ export function EditGameModal({ const [logoPath, setLogoPath] = useState(""); const [heroPath, setHeroPath] = useState(""); const [isUpdating, setIsUpdating] = useState(false); - + // Store default image URLs for non-custom games const [defaultIconUrl, setDefaultIconUrl] = useState(null); const [defaultLogoUrl, setDefaultLogoUrl] = useState(null); @@ -59,11 +59,17 @@ export function EditGameModal({ setIconPath(extractLocalPath(game.customIconUrl)); setLogoPath(extractLocalPath(game.customLogoImageUrl)); setHeroPath(extractLocalPath(game.customHeroImageUrl)); - + // Store default URLs for restore functionality from shopDetails.assets setDefaultIconUrl(shopDetails?.assets?.iconUrl || game.iconUrl || null); - setDefaultLogoUrl(shopDetails?.assets?.logoImageUrl || game.logoImageUrl || null); - setDefaultHeroUrl(shopDetails?.assets?.libraryHeroImageUrl || game.libraryHeroImageUrl || null); + setDefaultLogoUrl( + shopDetails?.assets?.logoImageUrl || game.logoImageUrl || null + ); + setDefaultHeroUrl( + shopDetails?.assets?.libraryHeroImageUrl || + game.libraryHeroImageUrl || + null + ); }; useEffect(() => { @@ -235,7 +241,7 @@ export function EditGameModal({ setIsUpdating(true); try { - const updatedGame = isCustomGame(game) + const updatedGame = game && isCustomGame(game) ? await updateCustomGame(game) : await updateNonCustomGame(game as LibraryGame); @@ -279,7 +285,7 @@ export function EditGameModal({ const isFormValid = gameName.trim(); const getIconPreviewUrl = (): string | undefined => { - if (!isCustomGame(game!)) { + if (game && !isCustomGame(game)) { // For non-custom games, show custom image if set, otherwise show default return iconPath ? `local:${iconPath}` : defaultIconUrl || undefined; } @@ -287,7 +293,7 @@ export function EditGameModal({ }; const getLogoPreviewUrl = (): string | undefined => { - if (!isCustomGame(game!)) { + if (game && !isCustomGame(game)) { // For non-custom games, show custom image if set, otherwise show default return logoPath ? `local:${logoPath}` : defaultLogoUrl || undefined; } @@ -295,7 +301,7 @@ export function EditGameModal({ }; const getHeroPreviewUrl = (): string | undefined => { - if (!isCustomGame(game!)) { + if (game && !isCustomGame(game)) { // For non-custom games, show custom image if set, otherwise show default return heroPath ? `local:${heroPath}` : defaultHeroUrl || undefined; } @@ -328,7 +334,7 @@ export function EditGameModal({ readOnly theme="dark" rightContent={ -
    +
    - {!isCustomGame(game!) && iconPath && ( + {game && !isCustomGame(game) && iconPath && ( - {!isCustomGame(game!) && logoPath && ( + {game && !isCustomGame(game) && logoPath && ( - {!isCustomGame(game!) && heroPath && ( + {game && !isCustomGame(game) && heroPath && ( {game && !isCustomGame(game) && iconPath && ( {game && !isCustomGame(game) && logoPath && ( {game && !isCustomGame(game) && heroPath && (
    From de4119988c3f3d54405d54f271d6c08baa4b5ea6 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Fri, 19 Sep 2025 22:30:16 +0300 Subject: [PATCH 16/22] avoiding non-native interactive elements + extracted ternary operation --- src/main/events/misc/delete-temp-file.ts | 2 +- src/main/events/misc/save-temp-file.ts | 11 +- .../game-details/game-details-content.tsx | 52 +++--- .../game-details/modals/edit-game-modal.scss | 22 ++- .../game-details/modals/edit-game-modal.tsx | 176 ++++++++++++------ 5 files changed, 170 insertions(+), 93 deletions(-) diff --git a/src/main/events/misc/delete-temp-file.ts b/src/main/events/misc/delete-temp-file.ts index b26dd975..7ca88fa1 100644 --- a/src/main/events/misc/delete-temp-file.ts +++ b/src/main/events/misc/delete-temp-file.ts @@ -15,4 +15,4 @@ const deleteTempFile = async ( } }; -registerEvent("deleteTempFile", deleteTempFile); \ No newline at end of file +registerEvent("deleteTempFile", deleteTempFile); diff --git a/src/main/events/misc/save-temp-file.ts b/src/main/events/misc/save-temp-file.ts index c9776430..8f253bf2 100644 --- a/src/main/events/misc/save-temp-file.ts +++ b/src/main/events/misc/save-temp-file.ts @@ -10,15 +10,18 @@ const saveTempFile = async ( ): Promise => { try { const tempDir = app.getPath("temp"); - const tempFilePath = path.join(tempDir, `hydra-temp-${Date.now()}-${fileName}`); - + 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); \ No newline at end of file +registerEvent("saveTempFile", saveTempFile); 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 f4619514..4ef45b6f 100644 --- a/src/renderer/src/pages/game-details/game-details-content.tsx +++ b/src/renderer/src/pages/game-details/game-details-content.tsx @@ -145,6 +145,34 @@ export function GameDetailsContent() { 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 (
    - {isCustomGame ? ( - // For custom games, show logo image if available, otherwise show game title as text - logoImage ? ( - {game?.title} - ) : ( -
    - {game?.title} -
    - ) - ) : ( - // For non-custom games, show logo image if available - logoImage && ( - {game?.title} - ) - )} + {renderGameLogo()}
    {(iconPath || (game && !isCustomGame(game) && defaultIconUrl)) && ( -
    handleDragEnter(e, 'icon')} + onDragEnter={(e) => handleDragEnter(e, "icon")} onDragLeave={handleDragLeave} onDrop={handleIconDrop} + onClick={handleSelectIcon} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleSelectIcon(); + } + }} > {t("edit_game_modal_icon_preview")} - {dragOverTarget === 'icon' && ( + {dragOverTarget === "icon" && (
    Drop to replace icon
    @@ -521,15 +549,27 @@ export function EditGameModal({
    )} - {(!iconPath && !(game && !isCustomGame(game) && defaultIconUrl)) && ( -
    handleDragEnter(e, 'icon')} + onDragEnter={(e) => handleDragEnter(e, "icon")} onDragLeave={handleDragLeave} onDrop={handleIconDrop} + onClick={handleSelectIcon} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleSelectIcon(); + } + }} >
    @@ -576,21 +616,33 @@ export function EditGameModal({
    {(logoPath || (game && !isCustomGame(game) && defaultLogoUrl)) && ( -
    handleDragEnter(e, 'logo')} + onDragEnter={(e) => handleDragEnter(e, "logo")} onDragLeave={handleDragLeave} onDrop={handleLogoDrop} + onClick={handleSelectLogo} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleSelectLogo(); + } + }} > {t("edit_game_modal_logo_preview")} - {dragOverTarget === 'logo' && ( + {dragOverTarget === "logo" && (
    Drop to replace logo
    @@ -598,13 +650,15 @@ export function EditGameModal({
    )} - {(!logoPath && !(game && !isCustomGame(game) && defaultLogoUrl)) && ( -
    handleDragEnter(e, 'logo')} + onDragEnter={(e) => handleDragEnter(e, "logo")} onDragLeave={handleDragLeave} onDrop={handleLogoDrop} > @@ -653,12 +707,14 @@ export function EditGameModal({
    {(heroPath || (game && !isCustomGame(game) && defaultHeroUrl)) && ( -
    handleDragEnter(e, 'hero')} + onDragEnter={(e) => handleDragEnter(e, "hero")} onDragLeave={handleDragLeave} onDrop={handleHeroDrop} > @@ -667,7 +723,7 @@ export function EditGameModal({ alt={t("edit_game_modal_hero_preview")} className="edit-game-modal__preview-image" /> - {dragOverTarget === 'hero' && ( + {dragOverTarget === "hero" && (
    Drop to replace hero image
    @@ -675,13 +731,15 @@ export function EditGameModal({
    )} - {(!heroPath && !(game && !isCustomGame(game) && defaultHeroUrl)) && ( -
    handleDragEnter(e, 'hero')} + onDragEnter={(e) => handleDragEnter(e, "hero")} onDragLeave={handleDragLeave} onDrop={handleHeroDrop} > From 393a04d7c39ffa231e05db09cdd00882d8c45c4e Mon Sep 17 00:00:00 2001 From: Moyasee Date: Fri, 19 Sep 2025 22:39:08 +0300 Subject: [PATCH 17/22] Complete fix for non-native interactive elements --- .../game-details/modals/edit-game-modal.scss | 15 +++++ .../game-details/modals/edit-game-modal.tsx | 60 ++++++++----------- 2 files changed, 39 insertions(+), 36 deletions(-) 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 index facb3fc4..bb6d5918 100644 --- a/src/renderer/src/pages/game-details/modals/edit-game-modal.scss +++ b/src/renderer/src/pages/game-details/modals/edit-game-modal.scss @@ -53,6 +53,21 @@ 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); } 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 index 66418ab4..26d33a95 100644 --- a/src/renderer/src/pages/game-details/modals/edit-game-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/edit-game-modal.tsx @@ -515,9 +515,8 @@ export function EditGameModal({
    {(iconPath || (game && !isCustomGame(game) && defaultIconUrl)) && ( -
    { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - handleSelectIcon(); - } - }} > Drop to replace icon
    )} -
    + )} {!iconPath && !(game && !isCustomGame(game) && defaultIconUrl) && ( -
    { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - handleSelectIcon(); - } - }} >
    Drop icon image here
    -
    + )}
    @@ -616,9 +602,8 @@ export function EditGameModal({
    {(logoPath || (game && !isCustomGame(game) && defaultLogoUrl)) && ( -
    { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - handleSelectLogo(); - } - }} > Drop to replace logo
    )} -
    + )} {!logoPath && !(game && !isCustomGame(game) && defaultLogoUrl) && ( -
    handleDragEnter(e, "logo")} onDragLeave={handleDragLeave} onDrop={handleLogoDrop} + onClick={handleSelectLogo} >
    Drop logo image here
    -
    + )}
    @@ -707,7 +689,9 @@ export function EditGameModal({
    {(heroPath || (game && !isCustomGame(game) && defaultHeroUrl)) && ( -
    handleDragEnter(e, "hero")} onDragLeave={handleDragLeave} onDrop={handleHeroDrop} + onClick={handleSelectHero} > Drop to replace hero image
    )} - + )} {!heroPath && !(game && !isCustomGame(game) && defaultHeroUrl) && ( -
    handleDragEnter(e, "hero")} onDragLeave={handleDragLeave} onDrop={handleHeroDrop} + onClick={handleSelectHero} >
    Drop hero image here
    -
    + )} From eeed34adcbc7678838a884a428e3db77185cb96c Mon Sep 17 00:00:00 2001 From: Moyasee Date: Sat, 20 Sep 2025 09:21:28 +0300 Subject: [PATCH 18/22] lint failing fix --- .../game-details/modals/edit-game-modal.scss | 2 +- .../game-details/modals/edit-game-modal.tsx | 52 +++++++++++-------- 2 files changed, 31 insertions(+), 23 deletions(-) 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 index bb6d5918..3a5d8943 100644 --- a/src/renderer/src/pages/game-details/modals/edit-game-modal.scss +++ b/src/renderer/src/pages/game-details/modals/edit-game-modal.scss @@ -61,7 +61,7 @@ text-decoration: none; outline: none; cursor: pointer; - + &:focus { outline: 2px solid var(--color-primary); outline-offset: 2px; 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 index 26d33a95..614d458b 100644 --- a/src/renderer/src/pages/game-details/modals/edit-game-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/edit-game-modal.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; import { useTranslation } from "react-i18next"; import { ImageIcon, ReplyIcon } from "@primer/octicons-react"; @@ -13,7 +13,7 @@ export interface EditGameModalProps { onClose: () => void; game: LibraryGame | Game | null; shopDetails?: ShopDetailsWithAssets | null; - onGameUpdated: (updatedGame: any) => void; + onGameUpdated: (updatedGame: LibraryGame | Game) => void; } export function EditGameModal({ @@ -48,29 +48,32 @@ export function EditGameModal({ }; // Helper function to set asset paths for custom games - const setCustomGameAssets = (game: LibraryGame | Game) => { + const setCustomGameAssets = useCallback((game: LibraryGame | Game) => { setIconPath(extractLocalPath(game.iconUrl)); setLogoPath(extractLocalPath(game.logoImageUrl)); setHeroPath(extractLocalPath(game.libraryHeroImageUrl)); - }; + }, []); // Helper function to set asset paths for non-custom games - const setNonCustomGameAssets = (game: LibraryGame) => { - setIconPath(extractLocalPath(game.customIconUrl)); - setLogoPath(extractLocalPath(game.customLogoImageUrl)); - setHeroPath(extractLocalPath(game.customHeroImageUrl)); + const setNonCustomGameAssets = useCallback( + (game: LibraryGame) => { + setIconPath(extractLocalPath(game.customIconUrl)); + setLogoPath(extractLocalPath(game.customLogoImageUrl)); + setHeroPath(extractLocalPath(game.customHeroImageUrl)); - // Store default URLs for restore functionality from shopDetails.assets - setDefaultIconUrl(shopDetails?.assets?.iconUrl || game.iconUrl || null); - setDefaultLogoUrl( - shopDetails?.assets?.logoImageUrl || game.logoImageUrl || null - ); - setDefaultHeroUrl( - shopDetails?.assets?.libraryHeroImageUrl || - game.libraryHeroImageUrl || - null - ); - }; + // Store default URLs for restore functionality from shopDetails.assets + 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) { @@ -82,7 +85,7 @@ export function EditGameModal({ setNonCustomGameAssets(game as LibraryGame); } } - }, [game, visible, shopDetails]); + }, [game, visible, shopDetails, setCustomGameAssets, setNonCustomGameAssets]); const handleGameNameChange = (event: React.ChangeEvent) => { setGameName(event.target.value); @@ -233,8 +236,13 @@ export function EditGameModal({ let filePath: string; // Try to get the path from the file object (Electron specific) - if ("path" in file && typeof (file as any).path === "string") { - filePath = (file as any).path; + // In Electron, File objects have a path property + interface ElectronFile extends File { + path?: string; + } + + if ("path" in file && typeof (file as ElectronFile).path === "string") { + filePath = (file as ElectronFile).path!; } else { // Fallback: create a temporary file from the file data const arrayBuffer = await file.arrayBuffer(); From 889f3bb77311d4d9907473d20870ca8f2b307b62 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Sun, 28 Sep 2025 17:40:18 +0300 Subject: [PATCH 19/22] conflicts fix --- .../game-details/game-details-content.tsx | 35 ++----------------- 1 file changed, 2 insertions(+), 33 deletions(-) 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 eeeb67c7..46dfcd01 100644 --- a/src/renderer/src/pages/game-details/game-details-content.tsx +++ b/src/renderer/src/pages/game-details/game-details-content.tsx @@ -1,6 +1,4 @@ import { useContext, useEffect, useMemo, useRef, useState } from "react"; -import { average } from "color.js"; -import Color from "color"; import { PencilIcon } from "@primer/octicons-react"; import { HeroPanel } from "./hero"; @@ -23,15 +21,8 @@ export function GameDetailsContent() { const { t } = useTranslation("game_details"); - const { - objectId, - shopDetails, - game, - gameColor, - setGameColor, - hasNSFWContentBlocked, - updateGame, - } = useContext(gameDetailsContext); + const { objectId, shopDetails, game, hasNSFWContentBlocked, updateGame } = + useContext(gameDetailsContext); const { showHydraCloudModal } = useSubscription(); @@ -67,28 +58,6 @@ export function GameDetailsContent() { const [backdropOpacity, setBackdropOpacity] = useState(1); const [showEditGameModal, setShowEditGameModal] = useState(false); - const handleHeroLoad = async () => { - // Use the same logic as heroImage to get the correct URL for both custom and non-custom games - const isCustomGame = game?.shop === "custom"; - const heroImageUrl = isCustomGame - ? game?.libraryHeroImageUrl || game?.iconUrl || "" - : getImageWithCustomPriority( - game?.customHeroImageUrl, - shopDetails?.assets?.libraryHeroImageUrl - ); - - const output = await average(heroImageUrl, { - amount: 1, - format: "hex", - }); - - const backgroundColor = output - ? new Color(output).darken(0.7).toString() - : ""; - - setGameColor(backgroundColor); - }; - useEffect(() => { setBackdropOpacity(1); }, [objectId]); From d78631a7f409fc37f4efc7e5a8c4c172243b8f35 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Sun, 28 Sep 2025 20:08:03 +0300 Subject: [PATCH 20/22] Fix: hide pin button if game is custom --- src/renderer/src/pages/game-details/hero/hero-panel-actions.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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/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.scss b/src/renderer/src/pages/game-details/game-details.scss index a5566a96..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,16 @@ $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 { @@ -116,14 +120,22 @@ $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: 300px; + width: 200px; align-self: flex-end; - font-size: 2.5rem; + font-size: 1.8rem; font-weight: bold; color: #ffffff; text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.8); @@ -132,6 +144,16 @@ $hero-height: 300px; 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 { @@ -173,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%; } @@ -206,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/modals/change-game-playtime-modal.tsx b/src/renderer/src/pages/game-details/modals/change-game-playtime-modal.tsx index 7355461a..6d5ef135 100644 --- a/src/renderer/src/pages/game-details/modals/change-game-playtime-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/change-game-playtime-modal.tsx @@ -149,17 +149,17 @@ export function ChangeGamePlaytimeModal({
    + + - -
    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 index 3a5d8943..5400df07 100644 --- a/src/renderer/src/pages/game-details/modals/edit-game-modal.scss +++ b/src/renderer/src/pages/game-details/modals/edit-game-modal.scss @@ -12,6 +12,28 @@ 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; 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 index 614d458b..04a27779 100644 --- a/src/renderer/src/pages/game-details/modals/edit-game-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/edit-game-modal.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useCallback } from "react"; import { useTranslation } from "react-i18next"; -import { ImageIcon, ReplyIcon } from "@primer/octicons-react"; +import { ImageIcon, XIcon } from "@primer/octicons-react"; import { Modal, TextField, Button } from "@renderer/components"; import { useToast } from "@renderer/hooks"; @@ -16,6 +16,8 @@ export interface EditGameModalProps { onGameUpdated: (updatedGame: LibraryGame | Game) => void; } +type AssetType = "icon" | "logo" | "hero"; + export function EditGameModal({ visible, onClose, @@ -31,37 +33,32 @@ export function EditGameModal({ const [logoPath, setLogoPath] = useState(""); const [heroPath, setHeroPath] = useState(""); const [isUpdating, setIsUpdating] = useState(false); + const [selectedAssetType, setSelectedAssetType] = useState("icon"); - // Store default image URLs for non-custom games const [defaultIconUrl, setDefaultIconUrl] = useState(null); const [defaultLogoUrl, setDefaultLogoUrl] = useState(null); const [defaultHeroUrl, setDefaultHeroUrl] = useState(null); - // Helper function to check if game is a custom game const isCustomGame = (game: LibraryGame | Game): boolean => { return game.shop === "custom"; }; - // Helper function to extract local path from URL const extractLocalPath = (url: string | null | undefined): string => { return url?.startsWith("local:") ? url.replace("local:", "") : ""; }; - // Helper function to set asset paths for custom games const setCustomGameAssets = useCallback((game: LibraryGame | Game) => { setIconPath(extractLocalPath(game.iconUrl)); setLogoPath(extractLocalPath(game.logoImageUrl)); setHeroPath(extractLocalPath(game.libraryHeroImageUrl)); }, []); - // Helper function to set asset paths for non-custom games const setNonCustomGameAssets = useCallback( (game: LibraryGame) => { setIconPath(extractLocalPath(game.customIconUrl)); setLogoPath(extractLocalPath(game.customLogoImageUrl)); setHeroPath(extractLocalPath(game.customHeroImageUrl)); - // Store default URLs for restore functionality from shopDetails.assets setDefaultIconUrl(shopDetails?.assets?.iconUrl || game.iconUrl || null); setDefaultLogoUrl( shopDetails?.assets?.logoImageUrl || game.logoImageUrl || null @@ -91,7 +88,47 @@ export function EditGameModal({ setGameName(event.target.value); }; - const handleSelectIcon = async () => { + 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: [ @@ -104,91 +141,24 @@ export function EditGameModal({ if (filePaths && filePaths.length > 0) { try { - // Copy the asset to the app's assets folder const copiedAssetUrl = await window.electron.copyCustomGameAsset( filePaths[0], - "icon" + assetType ); - setIconPath(copiedAssetUrl.replace("local:", "")); + setAssetPath(assetType, copiedAssetUrl.replace("local:", "")); } catch (error) { - console.error("Failed to copy icon asset:", error); - // Fallback to original behavior - setIconPath(filePaths[0]); + console.error(`Failed to copy ${assetType} asset:`, error); + setAssetPath(assetType, filePaths[0]); } } }; - const handleSelectLogo = async () => { - 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 { - // Copy the asset to the app's assets folder - const copiedAssetUrl = await window.electron.copyCustomGameAsset( - filePaths[0], - "logo" - ); - setLogoPath(copiedAssetUrl.replace("local:", "")); - } catch (error) { - console.error("Failed to copy logo asset:", error); - // Fallback to original behavior - setLogoPath(filePaths[0]); - } - } + const handleRestoreDefault = (assetType: AssetType) => { + setAssetPath(assetType, ""); }; - const handleSelectHero = async () => { - 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 { - // Copy the asset to the app's assets folder - const copiedAssetUrl = await window.electron.copyCustomGameAsset( - filePaths[0], - "hero" - ); - setHeroPath(copiedAssetUrl.replace("local:", "")); - } catch (error) { - console.error("Failed to copy hero asset:", error); - // Fallback to original behavior - setHeroPath(filePaths[0]); - } - } - }; - - // Helper functions to restore default images for non-custom games - const handleRestoreDefaultIcon = () => { - setIconPath(""); - }; - - const handleRestoreDefaultLogo = () => { - setLogoPath(""); - }; - - const handleRestoreDefaultHero = () => { - setHeroPath(""); - }; - - // Drag and drop state const [dragOverTarget, setDragOverTarget] = useState(null); - // Drag and drop handlers const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); @@ -203,7 +173,6 @@ export function EditGameModal({ const handleDragLeave = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); - // Only clear drag state if we're leaving the drop zone entirely if (!e.currentTarget.contains(e.relatedTarget as Node)) { setDragOverTarget(null); } @@ -220,10 +189,7 @@ export function EditGameModal({ return validTypes.includes(file.type); }; - const processDroppedFile = async ( - file: File, - assetType: "icon" | "logo" | "hero" - ) => { + const processDroppedFile = async (file: File, assetType: AssetType) => { setDragOverTarget(null); if (!validateImageFile(file)) { @@ -232,11 +198,8 @@ export function EditGameModal({ } try { - // In Electron, we need to get the file path differently let filePath: string; - // Try to get the path from the file object (Electron specific) - // In Electron, File objects have a path property interface ElectronFile extends File { path?: string; } @@ -244,11 +207,9 @@ export function EditGameModal({ if ("path" in file && typeof (file as ElectronFile).path === "string") { filePath = (file as ElectronFile).path!; } else { - // Fallback: create a temporary file from the file data const arrayBuffer = await file.arrayBuffer(); const uint8Array = new Uint8Array(arrayBuffer); - // Use a temporary file approach const tempFileName = `temp_${Date.now()}_${file.name}`; const tempPath = await window.electron.saveTempFile?.( tempFileName, @@ -264,31 +225,18 @@ export function EditGameModal({ filePath = tempPath; } - // Copy the asset to the app's assets folder using the file path const copiedAssetUrl = await window.electron.copyCustomGameAsset( filePath, assetType ); const assetPath = copiedAssetUrl.replace("local:", ""); - - switch (assetType) { - case "icon": - setIconPath(assetPath); - break; - case "logo": - setLogoPath(assetPath); - break; - case "hero": - setHeroPath(assetPath); - break; - } + setAssetPath(assetType, assetPath); showSuccessToast( `${assetType.charAt(0).toUpperCase() + assetType.slice(1)} updated successfully!` ); - // Clean up temporary file if we created one if (!("path" in file) && filePath) { try { await window.electron.deleteTempFile?.(filePath); @@ -304,7 +252,7 @@ export function EditGameModal({ } }; - const handleIconDrop = async (e: React.DragEvent) => { + const handleAssetDrop = async (e: React.DragEvent, assetType: AssetType) => { e.preventDefault(); e.stopPropagation(); setDragOverTarget(null); @@ -313,33 +261,7 @@ export function EditGameModal({ const files = Array.from(e.dataTransfer.files); if (files.length > 0) { - await processDroppedFile(files[0], "icon"); - } - }; - - const handleLogoDrop = async (e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setDragOverTarget(null); - - if (isUpdating) return; - - const files = Array.from(e.dataTransfer.files); - if (files.length > 0) { - await processDroppedFile(files[0], "logo"); - } - }; - - const handleHeroDrop = async (e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setDragOverTarget(null); - - if (isUpdating) return; - - const files = Array.from(e.dataTransfer.files); - if (files.length > 0) { - await processDroppedFile(files[0], "hero"); + await processDroppedFile(files[0], assetType); } }; @@ -444,28 +366,111 @@ export function EditGameModal({ const isFormValid = gameName.trim(); - const getIconPreviewUrl = (): string | undefined => { + const getPreviewUrl = (assetType: AssetType): string | undefined => { + const assetPath = getAssetPath(assetType); + const defaultUrl = getDefaultUrl(assetType); + if (game && !isCustomGame(game)) { - // For non-custom games, show custom image if set, otherwise show default - return iconPath ? `local:${iconPath}` : defaultIconUrl || undefined; + return assetPath ? `local:${assetPath}` : defaultUrl || undefined; } - return iconPath ? `local:${iconPath}` : undefined; + return assetPath ? `local:${assetPath}` : undefined; }; - const getLogoPreviewUrl = (): string | undefined => { - if (game && !isCustomGame(game)) { - // For non-custom games, show custom image if set, otherwise show default - return logoPath ? `local:${logoPath}` : defaultLogoUrl || undefined; - } - return logoPath ? `local:${logoPath}` : 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 getHeroPreviewUrl = (): string | undefined => { - if (game && !isCustomGame(game)) { - // For non-custom games, show custom image if set, otherwise show default - return heroPath ? `local:${heroPath}` : defaultHeroUrl || undefined; - } - return heroPath ? `local:${heroPath}` : undefined; + 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 ( @@ -486,266 +491,39 @@ export function EditGameModal({ disabled={isUpdating} /> -
    - - - {game && !isCustomGame(game) && iconPath && ( - - )} -
    - } - /> -
    - {t("edit_game_modal_icon_resolution")} +
    +
    + {t("edit_game_modal_assets")}
    - - {(iconPath || (game && !isCustomGame(game) && defaultIconUrl)) && ( - - )} - - {!iconPath && !(game && !isCustomGame(game) && defaultIconUrl) && ( - - )} + {t("edit_game_modal_logo")} + + +
    -
    - - - {game && !isCustomGame(game) && logoPath && ( - - )} -
    - } - /> -
    - {t("edit_game_modal_logo_resolution")} -
    - - {(logoPath || (game && !isCustomGame(game) && defaultLogoUrl)) && ( - - )} - - {!logoPath && !(game && !isCustomGame(game) && defaultLogoUrl) && ( - - )} - - -
    - - - {game && !isCustomGame(game) && heroPath && ( - - )} -
    - } - /> -
    - {t("edit_game_modal_hero_resolution")} -
    - - {(heroPath || (game && !isCustomGame(game) && defaultHeroUrl)) && ( - - )} - - {!heroPath && !(game && !isCustomGame(game) && defaultHeroUrl) && ( - - )} - + {renderImageSection(selectedAssetType)}
    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} >
    - -
    From 8f30f8a4ad09f20d761e4de9a93a7520dc3e128f Mon Sep 17 00:00:00 2001 From: Moyasee Date: Sun, 28 Sep 2025 20:17:02 +0300 Subject: [PATCH 22/22] fix: fixed edit game assets button not using translation --- src/locales/en/translation.json | 1 + src/renderer/src/pages/game-details/game-details-content.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index b1a9c61b..fbb7d479 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -272,6 +272,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/renderer/src/pages/game-details/game-details-content.tsx b/src/renderer/src/pages/game-details/game-details-content.tsx index 46dfcd01..347e5a1c 100644 --- a/src/renderer/src/pages/game-details/game-details-content.tsx +++ b/src/renderer/src/pages/game-details/game-details-content.tsx @@ -172,7 +172,7 @@ export function GameDetailsContent() { type="button" className="game-details__edit-custom-game-button" onClick={handleEditGameClick} - title={t("edit_custom_game")} + title={t("edit_game_modal_button")} >